From 5f4ff65b0f367083db7428b3b57211ccea0b43ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Oester?= Date: Tue, 20 Oct 2026 18:05:18 +0200 Subject: [PATCH] feat(antibot): a ban is told, with its end and a link to the rules A banned caller's clicks were answered OK and dropped. Most of the callers the rules banned in October 2026 were people, and for them a click that did nothing read as a bug, with no way to ask. Click, DropBomb and PlaceShield now refuse a banned caller with PermissionDenied and a planet.v1.BanRefusal detail: when the ban ends. No reason goes out. ban_click sits outside the throttle, so a refused click spends no token and feeds no watchdog, and the end the player is told does not move. The bomb or the shield stays in hand. The client shows "Clicks paused" until that time, with a link to the terms' Play fair section, which now names switching accounts. The shadowban package, config block and metrics are renamed to bans, antiBot.bans and banned_clicks / banned_callers / ban_flags. Co-Authored-By: Claude Opus 5.5 --- apps/backend/CLAUDE.md | 180 ++++--- apps/backend/cmd/api/config_test.go | 23 +- apps/backend/cmd/api/example.yaml | 2 +- .../generated/proto/planet/v1/planet.pb.go | 467 ++++++++++-------- apps/backend/internal/antibot/antibot.go | 47 +- apps/backend/internal/antibot/antibot_test.go | 27 +- .../internal/antibot/antibot_testing.go | 6 +- .../shadowban.go => bans/banner.go} | 9 +- .../shadowban_test.go => bans/banner_test.go} | 26 +- .../{shadowban/bans.go => bans/book.go} | 33 +- .../antibot/internal/bans/book_test.go | 131 +++++ .../{shadowban => bans}/helpers_test.go | 36 +- .../internal/{shadowban => bans}/store.go | 2 +- .../store_contract_test.go | 8 +- .../store_contract_testing.go | 2 +- .../{shadowban => bans}/store_test.go | 24 +- .../{shadowban => bans}/store_testing.go | 2 +- .../internal/antibot/internal/jury/jury.go | 27 +- .../antibot/internal/jury/jury_test.go | 29 +- .../antibot/internal/jury/state_test.go | 6 +- .../antibot/internal/jury/sweep_test.go | 14 +- .../internal/postgres_ban_store/store.go | 14 +- .../internal/postgres_ban_store/store_test.go | 14 +- .../antibot/internal/shadowban/bans_test.go | 131 ----- .../antibot_drop_bomb/usecase.go | 10 +- .../antibot_drop_bomb/usecase_test.go | 62 ++- .../prom_drop_bomb/usecase.go | 7 +- .../publishing_drop_bomb/usecase.go | 2 +- .../publishing_drop_bomb/usecase_test.go | 6 +- .../usecases/drop_bomb_usecase/usecase.go | 6 - .../drop_bomb_usecase/usecase_test.go | 11 - .../antibot_place_shield/usecase.go | 10 +- .../antibot_place_shield/usecase_test.go | 59 ++- .../usecases/place_shield_usecase/usecase.go | 10 +- .../place_shield_usecase/usecase_test.go | 10 - .../planet/internal/clicks/ban_refusal.go | 23 + .../click_usecase/antibot_click/observer.go | 2 +- .../click_usecase/antibot_click/usecase.go | 23 +- .../antibot_click/usecase_test.go | 20 +- .../click_usecase/ban_click/usecase.go | 40 ++ .../click_usecase/ban_click/usecase_test.go | 80 +++ .../clicks/usecases/click_usecase/usecase.go | 1 - .../internal/planetv1controller/ban_test.go | 51 ++ .../click_handler/errors.go | 5 + .../drop_bomb_handler/handler.go | 5 + .../place_shield_handler/handler.go | 5 + .../planetv1controller/planetban/refusal.go | 23 + .../planetban/refusal_test.go | 36 ++ apps/backend/internal/planet/module.go | 17 +- .../src/gen/grpc/planet/v1/planet_pb.ts | 39 ++ apps/frontend/CLAUDE.md | 62 ++- apps/frontend/src/app/chat/ChatComposer.tsx | 8 +- .../src/app/components/BannedModal.css | 3 + .../src/app/components/BannedModal.test.tsx | 44 ++ .../src/app/components/BannedModal.tsx | 22 + apps/frontend/src/app/viewer/Viewer.tsx | 4 + apps/frontend/src/app/viewer/globe.test.ts | 39 +- apps/frontend/src/app/viewer/globe.ts | 16 +- apps/frontend/src/app/viewer/useGlobe.ts | 4 + apps/frontend/src/backends/backend.ts | 10 + apps/frontend/src/backends/fakeBackend.ts | 13 + .../src/backends/planetBackend.test.ts | 36 +- apps/frontend/src/backends/planetBackend.ts | 10 +- apps/frontend/src/clip/main.ts | 1 + apps/frontend/src/domain/endTime.test.ts | 25 + apps/frontend/src/domain/endTime.ts | 11 + .../src/gen/grpc/planet/v1/planet_pb.ts | 39 ++ apps/frontend/terms.html | 8 +- deploy/vps/README.md | 38 +- deploy/vps/backend.yaml | 2 +- deploy/vps/docker-compose.yaml | 2 +- proto/planet/v1/planet.proto | 5 + 72 files changed, 1432 insertions(+), 793 deletions(-) rename apps/backend/internal/antibot/internal/{shadowban/shadowban.go => bans/banner.go} (96%) rename apps/backend/internal/antibot/internal/{shadowban/shadowban_test.go => bans/banner_test.go} (92%) rename apps/backend/internal/antibot/internal/{shadowban/bans.go => bans/book.go} (71%) create mode 100644 apps/backend/internal/antibot/internal/bans/book_test.go rename apps/backend/internal/antibot/internal/{shadowban => bans}/helpers_test.go (66%) rename apps/backend/internal/antibot/internal/{shadowban => bans}/store.go (96%) rename apps/backend/internal/antibot/internal/{shadowban => bans}/store_contract_test.go (62%) rename apps/backend/internal/antibot/internal/{shadowban => bans}/store_contract_testing.go (99%) rename apps/backend/internal/antibot/internal/{shadowban => bans}/store_test.go (83%) rename apps/backend/internal/antibot/internal/{shadowban => bans}/store_testing.go (98%) delete mode 100644 apps/backend/internal/antibot/internal/shadowban/bans_test.go create mode 100644 apps/backend/internal/planet/internal/clicks/ban_refusal.go create mode 100644 apps/backend/internal/planet/internal/clicks/usecases/click_usecase/ban_click/usecase.go create mode 100644 apps/backend/internal/planet/internal/clicks/usecases/click_usecase/ban_click/usecase_test.go create mode 100644 apps/backend/internal/planet/internal/planetv1controller/ban_test.go create mode 100644 apps/backend/internal/planet/internal/planetv1controller/planetban/refusal.go create mode 100644 apps/backend/internal/planet/internal/planetv1controller/planetban/refusal_test.go create mode 100644 apps/frontend/src/app/components/BannedModal.css create mode 100644 apps/frontend/src/app/components/BannedModal.test.tsx create mode 100644 apps/frontend/src/app/components/BannedModal.tsx create mode 100644 apps/frontend/src/domain/endTime.test.ts create mode 100644 apps/frontend/src/domain/endTime.ts diff --git a/apps/backend/CLAUDE.md b/apps/backend/CLAUDE.md index 0220cd19e..70107c988 100644 --- a/apps/backend/CLAUDE.md +++ b/apps/backend/CLAUDE.md @@ -315,7 +315,7 @@ The events today: | event | published by | when | heard by | |---|---|---|---| | `planet.v1.TileTaken{account_id, tile_id, country, taken_at}` | `planet`, `ledger/publishing_ledger_storage` | each take the ledger records with an account: a click, a spread or an enclose, one event per tile | `player`, for the stats; `seasons`, for the standings | -| `planet.v1.BombLanded{country, tile_id, ground, cleared, landed_at}` | `planet`, `drop_bomb_usecase/publishing_drop_bomb` | each bomb that went off, on land or in the sea; a refused drop and a dud publish nothing | `chat`, which announces it | +| `planet.v1.BombLanded{country, tile_id, ground, cleared, landed_at}` | `planet`, `drop_bomb_usecase/publishing_drop_bomb` | each bomb that went off, on land or in the sea; a refused drop publishes nothing | `chat`, which announces it | | `planet.v1.Fortified{country, landmass_id, tiles, ground, fortified_at}` | `planet`, `relay_fortifications_usecase` | each landmass a player's take fortified (see [Fortify](#fortify)) | `chat`, which announces the ones of 50 tiles or more | | `auth.v1.AccountDeleted{account_id}` | `auth`, `delete_account_usecase` and `prune_guests_usecase` | after the account row is deleted; a pruned guest is a deleted account | `player`, which forgets the profile, the stats, the titles, the title worn and the visit; `seasons`, which forgets the account's standings; `planet`, which takes the account off every take postgres keeps; `chat`, which forgets the seen mark; `support`, which takes the account off the notes it sent and deletes the reports that name it | | `auth.v1.SignedIn{previous_account_id, account_id}` | `auth`, `signin.Admitter`, for `complete_sign_in_usecase` and `complete_email_sign_in_usecase` | after a sign-in or a link is saved; `previous_account_id` is empty for a browser that had no live session | `player`, which moves the browser's visit to the account, and gives the account a username when it has none | @@ -412,7 +412,7 @@ because it serves every concept over one Connect service. It only maps. | `clicks/usecases/paint_random_tiles_usecase` | paints random tiles with a flag, starting on one country's ground or anywhere | `Borders`, `Neighbours`, `Map`, `CountryChecker` | | `ledger/usecases/find_players_usecase` | who is painting a flag, and where | `Ledger`, `Owners`, `Borders`, `Bans` | | `ledger/usecases/top_players_usecase` | who takes the most tiles, every flag | `Ledger`, `Owners`, `Bans` | -| `ledger/usecases/ban_player_usecase` | the operator's shadow ban | `Banner` | +| `ledger/usecases/ban_player_usecase` | the operator's ban | `Banner` | | `ledger/usecases/unban_player_usecase` | lifts a running ban and forgets its offence | `Unbanner` | | `ledger/usecases/inspect_player_usecase` | what the antibot holds on one caller | `Examiner` | | `ledger/usecases/revert_player_usecase` | gives back what one caller still holds | `Ledger`, `Map` | @@ -724,9 +724,10 @@ POST /planet.v1.ClickService/Click [X-Session-Token: ] [cpsessionverifier: the key from auth.v1.InternalService, asked once per boot then cpsession.Verifier: one signature check — this context holds no seed] → ClickService → click_handler - → antibot_attempt_click (times every try for the metronome; drops nothing) + → ban_click (a banned caller is PermissionDenied, with a BanRefusal: when the ban ends) + → antibot_attempt_click (times every try for the metronome; refuses nothing) → throttle_click (spends from the account's bucket and its scope's together, or refuses) - → antibot_click (judges; a flagged caller is answered OK and dropped) + → antibot_click (judges, and flags a caller it finds guilty; refuses nothing) → prom_click (counts) → clicks/usecases/click_usecase (validates tile ID + country) → ledger.Recording.Click() [records a take or a strike] @@ -811,16 +812,15 @@ to the jury. `FailedPrecondition` and spend nothing; no shield held is `NotFound`; an unknown country or tile is `InvalidArgument`. A placement the tile refuses after the charge was spent (it changed hands in between) gives the shield back. It is - session-gated like `DropBomb` and not throttled. `antibot_place_shield` makes a - banned caller's a `Dud`: the shield is spent, nothing is placed, and it is - answered OK. The answer is the charges left. + session-gated like `DropBomb` and not throttled. `antibot_place_shield` refuses a + banned caller before anything is spent, as `ban_click` does a click (see + [A ban is told](#a-ban-is-told)). The answer is the charges left. - **Kept with the tile**: `planet.tiles.shields`, written by the flush that writes the owner. -- **`click_usecase.Out.Outcome`** says what the rule did, for the decorators inside - the shadow ban: `enclose_click` closes a shape only with `Taken`, and `prom_click` +- **`click_usecase.Out.Outcome`** says what the rule did, for the decorators around + it: `enclose_click` closes a shape only with `Taken`, and `prom_click` counts `clicks_shielded_total{country_id}` by the flag clicked. **It never reaches - the wire**: a dropped click answers the zero `Out`, which reads `Unchanged`. The - client predicts it from the shields it holds. + the wire**. The client predicts it from the shields it holds. What a spread, an enclose, a bomb and the antibot do with a shielded tile is in their own sections: [spread](#what-a-spread-does-to-a-click), [enclose](#what-an-enclose-does-to-a-click), @@ -1765,7 +1765,7 @@ internal/seasons/internal/ **A player's season score is the tiles it took this season for its main flag**, the flag it took the most tiles for, so a player cannot add up several flags. **A country's board is every player who took tiles for that country, ranked by those tiles**, so a player is on the board of each flag it took for, and a change of flag shows at the first take rather than when the main flag moves. - **`standings.Tally` is the rule**: an account's tiles per flag in one season, and its main flag. `WithTake` adds one tile and makes the flag the main one when it has strictly more tiles than the main one; a tie keeps the flag that got there first. **The season of a take is the one whose interval holds `taken_at`** (`Take.Season`, over `Calendar.Current`); a take after the last season counts nothing. `tally_test.go` and `take_test.go` pin both. -- **Takes come from `planet.v1.TileTaken`** (`seasons-standings`), one tile per event. A strike on a shield, a shadow-banned click (it never reaches the ledger) and a revert publish nothing, so they change no score. Takes before this module had a database are not replayed: counting started the day it shipped. +- **Takes come from `planet.v1.TileTaken`** (`seasons-standings`), one tile per event. A strike on a shield, a click the ban refuses (it never reaches the ledger) and a revert publish nothing, so they change no score. Takes before this module had a database are not replayed: counting started the day it shipped. - **One transaction per take**, under `pg_advisory_xact_lock` on the account: read the account's rows for the season, apply `WithTake`, upsert the flag's row and move `main`. The rule stays in Go; the column only keeps what it said. - **Kept in `seasons.contributions`** (`season`, `account_id`, `country`, `tiles`, `main`; primary key `(season, account_id, country)`), with its own block, `seasons.database`, its own pool and migrations. `main` marks one row per account and season (a partial unique index). A partial index on it serves the whole map's board, and `contributions_country_board` (`season, country, tiles DESC, account_id`, every row) serves a country's. - **`auth.v1.AccountDeleted`** (`seasons-standings-accounts`) deletes the account's rows in every season. @@ -2126,10 +2126,10 @@ its own. shield placed. After that the client follows its own calls: a drop spends the bomb, an accepted click sent with spread on a spread click, its own `tiles_enclosed` an enclosure. A charge spent in another - tab shows until the next read. **`Click` answers nothing about them on purpose**: - a shadow-banned click never reaches the spread, so a count on the answer would - tell a banned caller its clicks are dropped. `planetv1controller/chargesheld` - encodes the message both procedures answer. + tab shows until the next read. **`Click` answers nothing about them.** It was so + a shadow-banned caller could not tell its clicks were dropped; a ban is told now + (see [A ban is told](#a-ban-is-told)), so the answer could carry them. + `planetv1controller/chargesheld` encodes the message both procedures answer. - **How big a charge is, is a rule, not state.** `GetBonusRules` answers the blast radius, the enclose's `maxTiles`, the spread pool's size and the enclosure stack's size (`bonuses.Rules`, built in `module.go`), how many @@ -2154,10 +2154,10 @@ it: a refused click spreads nothing and costs nothing, and a click with spread o never touches the pool. `click/spread_click` is the decorator that reads it, and **it sits right against -the rule**, inside the count, the shadow ban and the throttle: +the rule**, inside the count, the ban and the throttle: - a click is **one click** to the throttle and to `prom_click`, however many tiles it took -- a click the shadow ban drops never reaches the rule, so it spreads nothing +- a click the ban refuses never reaches the rule, so it spreads nothing - a click the rule refuses (unknown country, tile out of range) spreads nothing - the neighbours are not reported to the antibot jury — only the clicked tile is a click the caller made @@ -2237,11 +2237,10 @@ by `publishing_drop_bomb`, inside the count, and the chat announces it — see **And the ledger keeps it**: the drop clears through `ledger.Recording`, so every bomb that went off is one event in `planet.ledger_events`, with the flag each tile it cleared wore and the shields each tile it struck kept — see [Manual bans](#manual-bans-findplayers-topplayers-banplayer-unbanplayer-revertplayer-inspectplayer). -**The shadow ban applies**: `antibot_drop_bomb` marks a banned caller's drop as a -`Dud`, which spends the bomb, clears nothing and publishes nothing, and is answered -OK — a bomb left in hand would tell the caller it was refused. It sits outside the -count so the counter can label it. -`prom_drop_bomb` counts `bonus_bombs_dropped_total{outcome=land|sea|refused|shadowbanned}` +**The ban applies**: `antibot_drop_bomb` refuses a banned caller's drop before the +bomb is spent, with the same `BanRefusal` as a click (see [A ban is told](#a-ban-is-told)), +so the bomb stays in hand. It sits inside the count so the counter can label it. +`prom_drop_bomb` counts `bonus_bombs_dropped_total{outcome=land|sea|banned|refused}` and `bonus_bomb_tiles_cleared_total`. #### What an enclose does to a click @@ -2286,7 +2285,7 @@ tiles and announces them. `Execute` asks whether the charge is held, lets the rule write, and hands the pockets to the annexer. It sits beside `spread_click`, against the rule and inside everything else, so -it is one click to the throttle and to `prom_click`, a shadow-banned click never +it is one click to the throttle and to `prom_click`, a click the ban refuses never reaches it, and the tiles it takes are not reported to the antibot jury. The annexer hands the inside to `ledger.Recording.Enclose`, which claims each tile as an ordinary `Set` and keeps the enclosure as one event. The search holds no lock across the map, so a @@ -2337,8 +2336,8 @@ check can tell them from a player. The signal that survives is **behavioural**. of it: `Config`, `Observer`, `Guard`, `New` and `Description` to wire it, plus `Click`, `Point`, `Report`, `Sentence`, `Examination` and `Reading` — the types a caller writes down, because it builds two and is handed the others. A caller hands over the block and the two hooks it wants -findings reported through, and gets back a `Guard` — one that drops and bans nothing when the block is off, so -the DI sequence wires it the same way either way — that answers `Attempted`, `Inspect`, `Committed`, `Caught`, `Missed`, `Fetched`, `Listened`, `Flagged`, `Banned`, `LoadState`, `Run` and `Enabled`, plus `Ban`, +findings reported through, and gets back a `Guard` — one that refuses and bans nothing when the block is off, so +the DI sequence wires it the same way either way — that answers `Attempted`, `Inspect`, `Committed`, `Caught`, `Missed`, `Fetched`, `Listened`, `Flagged`, `EnforcedSentence`, `LoadState`, `Run` and `Enabled`, plus `Ban`, `Unban`, `Sentence`, `Enforcing` and `Examine` for the operator tools (see [Operator tools](#operator-tools-adminservice)). It is **one** `Run` whatever the file turned on: how many sweepers there are is this package's business, which is why `planet` registers one runner rather than one per sweeper. @@ -2362,21 +2361,45 @@ reflection, so a caller sets `config.Retaker.Detector.MaxSpread` without ever naming a type. Everything else is under `antibot/internal/`, so the click edge -could not assemble a jury out of watchdogs even if it wanted to. The shadow-ban step of +could not assemble a jury out of watchdogs even if it wanted to. The antibot step of the DI sequence in `internal/planet/module.go` is the whole of the clicks side, and what is left in it is genuinely the edge's: the metric names, the message and attribute names of the ban line, and where in the chain it sits. **Detection and consequence are separate, and the consequence is the boring -half.** `antibot/internal/shadowban` takes a scope or an account and a clock and runs a ban. It knows +half.** `antibot/internal/bans` takes a scope or an account and a clock and runs a ban. It knows nothing about tiles, reactions or what earned it, which is why the same sentence serves three different findings and would serve a fourth. -**A flagged caller's clicks are answered `OK` and dropped.** That is the whole -point — a refusal names the check that tripped, and the author fixes it in an -afternoon; a silent no-op names nothing. It is not permanent (the caller reads -the map back over the same stream and will notice), but it moves the cost of -the next round onto them. +#### A ban is told + +**A banned caller's click, bomb and shield are refused, with when the ban ends.** Until 2026-10-20 +they were answered `OK` and dropped, so a script author learned nothing. But most of the callers the +rules banned in October 2026 were people (`stamina` on Oct 7, `cadence` on Oct 9, `roam` on Oct 10, +`rotator` on Oct 18), and for them a click that did nothing read as a bug, with no way to ask. + +- **`PermissionDenied` with a `planet.v1.BanRefusal` detail**: `banned_until_unix_ms`, the end of the + ban that ends last of the scope's and the account's (`Guard.EnforcedSentence`). The VPN block answers + the same code with no detail, which is how the client tells the two apart; an older client shows its + VPN dialog. `clicks.BanRefusal` is the domain's error, and `planetban.Refusal` maps it in + `click_handler`, `drop_bomb_handler` and `place_shield_handler`. +- **No reason goes out.** The client links the terms' "Play fair" section (`/terms#play-fair`). The + rule that fired is in the `antibot ban` log line and in `InspectPlayer`, never on the wire. +- **`ban_click` sits outside the whole click chain**, as the VPN block does: a refused click spends no + token, or the client would show a 429 instead of the ban, and it reaches no watchdog. + `antibot_drop_bomb` and `antibot_place_shield` refuse before the bomb or the shield is spent, so it + stays in hand. +- **The click that earns the ban goes through**: `antibot_click` judges and refuses nothing, and the + next click meets `ban_click`. +- **So a ban no longer feeds itself**: the watchdogs never see the clicks of a caller while it is + banned, and the end the player was told does not move however much it clicks. Only an accomplice's + click can extend it (see the `rotator`). The ban lapses on its own, and the evidence windows have + forgotten the caller by then. +- **`banned_clicks`** counts the clicks refused. Until 2026-10-20 it was `shadowbanned_clicks`, and the + ban metrics beside it `shadowban_flagged` (`banned_callers`) and `shadowban_flags` (`ban_flags`): the + metrics poller's history files before that day carry the old names. The config block was + `antiBot.shadowBan` and the package `antibot/internal/shadowban`. +- `TestABannedCallerIsToldUntilWhenAndSpendsNoToken` pins it over HTTP. #### Ten watchdogs, one jury @@ -2441,18 +2464,18 @@ third: loosening `metronome` to catch it sooner is how the obsessed player gets banned. `hopper` is one, for shuffled ids that land all over the globe: `TestALoopAimingAllOverTheGlobeIsCaughtBeforeTheClockIsSure`. -**Every watchdog sees every click, including the ones a ban is already -dropping.** A watchdog cut off the moment another one banned the caller would be -judging a caller that appears to have stopped, and the ban would lapse on silence -the caller never produced. It also means a ban sustains itself: while it runs, -the caller takes no tiles so `retaker` starves, but ids and timing still flow, so -`sequencer` and `metronome` keep re-flagging through `reflagInterval`. +**Every watchdog sees every click until the caller is banned, and none while it is.** Until +2026-10-20 they saw the clicks a ban was dropping too, so a ban sustained itself: `sequencer` and +`metronome` kept re-flagging it through `reflagInterval`. A ban the player is told must not move +under it, so `ban_click` refuses those clicks before the jury (see [A ban is told](#a-ban-is-told)). +The scenario tests' stack still hands every click to the jury: what they pin is when a ban first +falls. #### What survives a restart **Bans and evidence both, in postgres, in the `antibot` schema.** Bans are `antibot.bans`, one row per scope ever banned, and `antibot.account_bans`, one row per -account, read and written by `antibot/internal/shadowban` with no copy in memory. What +account, read and written by `antibot/internal/bans` with no copy in memory. What each watchdog is tracking and the jury's record of each caller — its tally and the last opinion of every watchdog — are `antibot.evidence`, one row per section, written by `antibot/internal/evidence`. This exists because of 2026-09-14: production @@ -2469,7 +2492,7 @@ in memory no window of 10m (`suspicionWindow`), 15m (`trackWindow`) or 30m `Guard.LoadState(ctx)` connects, migrates and loads the evidence, and **an error refuses the boot**. The guard's `Run` closes the pool after the last flush of the evidence, the way `cppg.CloseAfter` does for the tile map. -- **Bans are postgres, and nothing else.** `shadowban.Store` is the port: `Record` +- **Bans are postgres, and nothing else.** `bans.Store` is the port: `Record` reads one key, `Running` counts the bans running, and `Change` runs a function on one key's record under `pg_advisory_xact_lock` and writes what it answers, in one transaction. The ladder, the reflag interval and the unban stay in Go, on the @@ -2480,14 +2503,14 @@ in memory no window of 10m (`suspicionWindow`), 15m (`trackWindow`) or 30m next: the reflag interval is added in Go, so a new `reflagInterval` applies to every row, and a restart is no way around it. The ban's end is `expires_at` (migration `20261008120000` added the first and renamed `banned_until` to the second). - `postgres_ban_store` is the adapter and `MemoryStore` (behind the `testing` tag) the fake; `shadowban.StoreContractSuite` runs on both, + `postgres_ban_store` is the adapter and `MemoryStore` (behind the `testing` tag) the fake; `bans.StoreContractSuite` runs on both, and pins the lock: changes to one key never overlap. -- **Every click reads them**, in `Guard.Inspect`, and a bomb or a shield in - `Guard.Banned`: one primary-key lookup on the scope and one on the account, and - none at all with `enforce` off. A read gets `banTimeout` (1s). **A ban that cannot +- **Every click, bomb and shield reads them**, in `Guard.EnforcedSentence` (`ban_click`, + `antibot_drop_bomb`, `antibot_place_shield`): one primary-key lookup on the scope and one on the + account, and none at all with `enforce` off. A read gets `banTimeout` (1s). **A ban that cannot be read is no ban**: the click goes through, and the error reaches `Observer.OnStateError`, which logs it. Postgres down lets a banned bot click, - rather than every player's clicks being dropped without a word. The operator + rather than every player's clicks being refused. The operator tools answer the error instead. - **A row is never deleted.** Scopes and accounts are two `Banner`s over two tables, each with its own ladder. An offence is forgotten only when an operator @@ -2721,9 +2744,7 @@ tiles, and every watchdog read `clear`. tokens spent, and tokens are the account's; seven accounts taking turns behind one address are seven players here. The reading still lands on the scope's jury record, like every watchdog's. -- **It counts in `Watch`, not `Attempted`**: a try the throttle refused spent nothing, - and a click a ban is dropping still passed the throttle, so a running ban keeps - reading. +- **It counts in `Watch`, not `Attempted`**: a try the throttle refused spent nothing. - **Measured before it was set**, over three days of ledger per account: the bot's two accounts read 6h of 6h, the heaviest player 4h50m, and 5 of 555 accounts 3h or more. - **It only reads `Suspect`, since 2026-10-08.** Production shipped `certainBusy` @@ -3070,7 +3091,7 @@ honest player flagged: `Set` publishes nothing, so it neither reacts nor becomes something to react to - a click the handler refused (invalid country, tile out of range) — it changed no tile -- a click that was itself dropped by the ban — same reason +- a click the ban refused — it never reaches the jury at all The interceptor reads the tile's owner **before** the handler runs and puts it on `antibot.Click` as `Held`/`NoOp`, because afterwards the map no longer remembers @@ -3092,21 +3113,19 @@ the walk. **It is a decorator over the click use case** (`antibot_click`), not an interceptor. None of what it does is about HTTP: it reads who held the tile before the write, -reports the take afterwards, and answers a flagged caller OK with nothing -written — and the middle one only works adjacent to the write, because -afterwards the map no longer remembers who held the tile. +judges, and reports the take afterwards — and that only works adjacent to the write, +because afterwards the map no longer remembers who held the tile. -**It sits innermost, inside the throttle** — the opposite of the blocklist and the -session check. A shadow-banned caller has to keep hitting the same 429s everyone -else does; a caller that is never throttled again has been told. `TestAntiBotRunsAfterTheThrottle` -pins it. +**It sits innermost, inside the throttle**, so the watchdogs judge only the clicks +the throttle let through: a try it refused spent nothing. -**Only the watching is outside it.** `antibot_attempt_click` wraps the throttle and -reports each try through `Attempted`, which judges and drops nothing. Moving the -whole guard out instead would let a banned caller skip its 429s. +**Only the watching and the refusing are outside it.** `antibot_attempt_click` wraps +the throttle and reports each try through `Attempted`, which judges and refuses +nothing. `ban_click` wraps that, and refuses a banned caller before anything else +(see [A ban is told](#a-ban-is-told)). -**`antiBot.shadowBan.enforce` is the rollout switch,** the same shape as -`auth.enforce`: false judges, logs and counts without dropping anything. The +**`antiBot.bans.enforce` is the rollout switch,** the same shape as +`auth.enforce`: false judges, logs and counts without refusing anything. The two surfaces are built for a box with no dashboard — `click_reaction_seconds` is a histogram whose raw bucket counts show the bot band by eye, and each flag writes one `antibot ban` log line carrying the scope, every watchdog's verdict @@ -3122,11 +3141,11 @@ log and never an input to a rule on its own: the client declares it, so it is ch editing one string, and real players paint the same flags a bot does. `cohort` only uses the flag to group scopes that already started together — see above. -**A flag repeats, and that is most of its value.** `reflagInterval` is how soon a -caller already serving a ban can be judged again; at or above a watchdog's -`trackWindow`, each flag rests on evidence the previous one never saw, so -`flags=6` on a line is six independent judgements agreeing rather than one -verdict repeated. +**A flag on a running ban is rare now.** `reflagInterval` is how soon a caller +already serving a ban can be judged again, but a banned caller's clicks never reach +the jury (see [A ban is told](#a-ban-is-told)): only an accomplice's click, or a scope +banned while its account is not, flags it again. A second ban after one has lapsed +is a new offence, and climbs the ladder. **A day with no ban still says how close it came.** A ban is the only thing `OnFlag` reports, so on 2026-09-14 a bot attack produced zero lines and zero @@ -3156,19 +3175,19 @@ words it on the way out. **The watchdogs judge a scope; a ban falls on the scope and the account.** The evidence stays keyed on `cpipscope.Of`, so a v6 caller cannot serve a ban on one address and click from the next in its own /64. A click carries the account its -token names (`Click.Account`), and `shadowban.Bans` passes a ban on: +token names (`Click.Account`), and `bans.Book` passes a ban on: -- **the account**, when the token names one, so it is dropped from any scope; +- **the account**, when the token names one, so it is refused from any scope; - **the scope too, when there is no account or the account is a guest's** (`Click.SignedIn` false: the token is not linked). A guest can shed its account with a new cookie, and must not shed the ban with it. A signed-in account's ban leaves its scope alone, so a campus is not banned for one player on it. -**A click, and a bomb, are dropped when the scope or the account is banned** -(`Guard.Banned(scope, account)`). `TestAGuestBannedByTheJuryCannotShedItWithAFreshCookie` +**A click, a bomb and a shield are refused when the scope or the account is banned** +(`Guard.EnforcedSentence(scope, account)`). `TestAGuestBannedByTheJuryCannotShedItWithAFreshCookie` and `TestABannedGuestWithAFreshCookieIsStillDropped` pin it. The `antibot ban` line -carries `account`, and `shadowban_flagged` counts running bans on scopes and on +carries `account`, and `banned_callers` counts running bans on scopes and on accounts, so a guest banned on both counts twice. **A ban on one account of a ring falls on all of it.** The `rotator` names the @@ -3368,16 +3387,16 @@ stream sent it. The frontend's clip generator plays it back (see its CLAUDE.md, For the patterns no watchdog catches but a person sees on the map. A player is an **account on a scope** (`cpipscope`: the address over IPv4, the /64 over IPv6), or a scope alone for takes made with no account. `BanPlayer`, `UnbanPlayer`, `RevertPlayer` and `InspectPlayer` take a `scope` (any address) **or** an `account_id`, never both (`ledger.ParseCaller`; both, neither or a malformed id is `InvalidArgument`). -- **`ledger` remembers every act of a player on the map**: who, when, the flag, the tile, and what it did to each tile, oldest first. `ledger.Recording` is the tile writer the click chain, `drop_bomb_usecase` and `place_shield_usecase` write through, with one method per act (`Click`, `Spread`, `Enclose`, `Clear`, `Shield`), so each act is one event, a click that changed nothing is none, and a click or a bomb the shadow ban drops never reaches it. Recording appends through `publishing_ledger_storage`, which publishes `planet.v1.TileTaken` for each take with an account, after it is recorded. A take by somebody else is one more take, not a replacement: a bot painted over as fast as it paints is still in the ledger. Reassigns, paints and reverts write nothing; they show as a change the ledger never saw. +- **`ledger` remembers every act of a player on the map**: who, when, the flag, the tile, and what it did to each tile, oldest first. `ledger.Recording` is the tile writer the click chain, `drop_bomb_usecase` and `place_shield_usecase` write through, with one method per act (`Click`, `Spread`, `Enclose`, `Clear`, `Shield`), so each act is one event, a click that changed nothing is none, and a click or a bomb the ban refuses never reaches it. Recording appends through `publishing_ledger_storage`, which publishes `planet.v1.TileTaken` for each take with an account, after it is recorded. A take by somebody else is one more take, not a replacement: a bot painted over as fast as it paints is still in the ledger. Reassigns, paints and reverts write nothing; they show as a change the ledger never saw. - **The ledger is a log of `ledger.Event`s, and nothing outside a kind's own file asks which kind it holds.** An event does two things: `Replay(see)` hands over each tile it changed, as a `Taking`, and `Entry()` writes it down. `ledger.EventOf(entry)` reads one back through `kinds`, the catalog, which is the only list of kinds. So the storage, the store, `Runs`, `Tally` and the publisher see events and changes, never a kind, and **a new kind is a type and one line in `kinds`**. A take replays as itself. - **The kinds**: a click that took a tile is a `Taking`, and one a shield stopped a `Striking`, which replays nothing. A spread or an enclosure is one event beside the click's (`Spreading`, `Enclosing`): the `clicks.Impact` of each tile it took or struck, sorted by tile, replayed as a take of each tile it took. A shield placed is a `Shielding`, and replays nothing. **`Tally` counts acts**: a click with a spread that took a neighbour is two takes in `TopPlayers`, and a strike or a shield is none. `TileTaken` is still one per tile taken, so the stats and the seasons count tiles as before. -- **A bomb is one event** (`ledger.Bombing`): who dropped it, when, and the blast as the map cleared it. `drop_bomb_usecase` clears through `Recording.Clear`, and the map's `Clear` reports the flag each tile wore under its own lock (`clicks.Blast.Owners`). A dud never reaches it; a bomb in the sea is kept too, and clears nothing. It replays as a take with no country on each tile it cleared, by the bomber. **So a revert undoes a bomb**: a bomber's run on each tile it cleared goes back to the flag it wore, while the tile is still empty, and a bomb breaks the run of whoever it hit. **`Tally` counts takes by event**: a bomb is one take for the bomber in `TopPlayers` and holds nothing, it ends the hold of each tile it hit, and it matches no flag in `FindPlayers`. It is never published, since every tile it changed is a clear: `BombLanded` already says it. +- **A bomb is one event** (`ledger.Bombing`): who dropped it, when, and the blast as the map cleared it. `drop_bomb_usecase` clears through `Recording.Clear`, and the map's `Clear` reports the flag each tile wore under its own lock (`clicks.Blast.Owners`). A refused drop never reaches it; a bomb in the sea is kept too, and clears nothing. It replays as a take with no country on each tile it cleared, by the bomber. **So a revert undoes a bomb**: a bomber's run on each tile it cleared goes back to the flag it wore, while the tile is still empty, and a bomb breaks the run of whoever it hit. **`Tally` counts takes by event**: a bomb is one take for the bomber in `TopPlayers` and holds nothing, it ends the hold of each tile it hit, and it matches no flag in `FindPlayers`. It is never published, since every tile it changed is a clear: `BombLanded` already says it. - **The rules are in the `ledger` root, and its package doc states them**. A caller (`ledger.Caller`, a scope or an account) **holds** a tile when the tile's latest take is its own and the tile still wears that paint. A revert gives a held tile back to what it held before the caller's **current run** on it: its own latest takes, walking back while each took the tile from the paint of the one before. An account's run follows it across scopes. Another scope's take breaks the run (A il→ps, B ps→de, A de→ps goes back to `de`), and so does a change the ledger never saw (A il→ps, a revert of A to nobody, A ""→ps goes back to nobody). `Tally` gathers players for `FindPlayers` and `TopPlayers`, `Runs` computes the revert, `ByTakes` and `Top` rank and cut. The use cases only replay the ledger into these, filter through their ports, and call them. The tests for each interleaving are in `ledger_test.go`. - **Kept in memory and flushed to postgres** (`inmemory_ledger_storage`, behind `ledger.Storage`; see [Durability](#durability)). Postgres keeps every event for good, without its scope once memory has dropped it; the tools here read memory alone. In memory it is an append-only log of 24-byte records in 1.5 MiB chunks, one per entry, scopes and accounts interned in one table per chunk and countries and kinds in others, so an old chunk takes its strings when it goes. An entry's payload sits beside its record in the chunk, in position order, and a take has none. `Replay` reads each entry back through `EventOf`; `Load` refuses the boot on one that does not read back (`ErrUnknownKind`), so a rollback past a new kind is refused, not skipped. A record is never changed once written, so `Replay` copies the chunk headers under the lock and reads without it: a `TopPlayers` over 4M takes takes ~1s and never blocks a click. `Forget(caller, position)` hides a reverted scope's or account's takes up to the replay it was computed from, so a take made mid-revert still counts. - **Memory is bounded twice; postgres is not.** `ledger.retention` (72h) drops takes from memory by age, each `ledger.sweepInterval`; `ledgerStorage.maxTakes` (4M) drops the oldest first when a busy stretch fills it, and logs "the ledger is full" once. Production is thousands of clicks per 5 minutes (`clicks_total`), and a spread click takes up to 7 tiles: 15 takes a second fill 4M in three days. Measured at 4M before the account: ~85 MiB heap. The account and the kind add 4 bytes each a take, about 30 MiB more at the cap (not measured). - **`FindPlayers(flag, area, limit)`** lists every player that took a tile for `flag` on `area`'s ground (empty is the whole map), latest take first, with any running ban on its scope or its account — whichever ends last. Each account on a scope is its own row, with `account_id`. The ground comes from `clicks.Borders`, built by `embedded_geodesic_map.Loader.LoadBorders` from the borders blob the frontend paints flags from — see [Map geography](#map-geography). A blob for another map refuses the boot. - **`TopPlayers(limit)`** is the same over every flag and the whole map, **most takes first, then most tiles held**, then latest take. Takes lead because they are what a painted-over bot cannot hide. Every player, in both answers, carries `tiles` (held) and `takes` (every take, a tile taken twice counting twice), `active_for` (last take minus first take), and `tiles_per_minute` and `takes_per_minute` over that. A player with `takes` high and `tiles` near zero is painting and being painted over. **`flag_country_id` is the flag it took the most tiles for**, held or painted over since, and of a tie the first by id (`Tally.pickFlags`); a bomb takes no tile for any flag, so a player that only bombed has none. In `FindPlayers` it counts only the takes the list counts, so it is the flag asked. No row carries a name: the admin page asks `player.v1.AdminService/GetAuthors` for those. -- **`BanPlayer(scope | account_id, duration)`** is `shadowban.Bans.Ban`, and drops the caller's clicks and bombs alike: the same record, ladder and table as a watchdog's ban, and it counts as an offence. It skips `reflagInterval`, and an empty duration takes the ladder's step. Any address is accepted and banned as its scope (`cpipscope.Parse`). **An account is banned alone**: the operator named no scope, and a guest that sheds it with a new cookie is a second ban on its scope away. **It follows `antiBot.shadowBan.enforce`**, and says so in `enforced`. With `antiBot.enabled` false it answers `FailedPrecondition`. +- **`BanPlayer(scope | account_id, duration)`** is `bans.Book.Ban`, and refuses the caller's clicks, bombs and shields alike: the same record, ladder and table as a watchdog's ban, and it counts as an offence. It skips `reflagInterval`, and an empty duration takes the ladder's step. Any address is accepted and banned as its scope (`cpipscope.Parse`). **An account is banned alone**: the operator named no scope, and a guest that sheds it with a new cookie is a second ban on its scope away. **It follows `antiBot.bans.enforce`**, and says so in `enforced`. With `antiBot.enabled` false it answers `FailedPrecondition`. - **`RevertPlayer(scope | account_id, dry_run)`** gives each tile the caller holds back to what it held before its run, by the rule above. A scope reverts every account's takes on it; an account reverts its takes from every scope. `touched` is the tiles it took, `held` those it still holds. **Only a tile still wearing the scope's paint changes** — `inmemory_tile_storage.Restore` is a compare-and-set under the lock, so a tile retaken mid-revert stays retaken. Paced like the reassign (`clicks.Pacing`), each tile an ordinary `TileUpdate`. A tile that was nobody's goes back to nobody, as an update with an empty country. It then forgets the caller's takes, so a second run does nothing; an interrupted one forgets nothing and can be run again. - **Ban before reverting**: an unbanned player repaints behind the revert. - **`UnbanPlayer(scope | account_id)`** lifts the caller's latest ban, for a false positive: its offence is @@ -3390,9 +3409,8 @@ For the patterns no watchdog catches but a person sees on the map. A player is a answers, so a restart keeps the caller unbanned: no restart and no SQL. It lifts the key it is given, so **a guest the jury banned needs two calls**, its account and its scope. A caller with no offence left is `NotFound` (`ErrNotBanned`), which a typo or the wrong half shows loudly; with `antiBot.enabled` false it is `FailedPrecondition`, as `BanPlayer` is. - - **It does not touch the evidence.** The watchdogs read their own on every click, the dropped ones included, - so a rule that still reads `certain` bans the caller again on its first click after `reflagInterval`, as a - first offence. **Fix the rule first**, then unban. `InspectPlayer` says which rule it was. Forgetting the + - **It does not touch the evidence.** The watchdogs kept what they read before the ban, so a rule that still + reads `certain` bans the caller again on its first click after the unban, as a first offence. **Fix the rule first**, then unban. `InspectPlayer` says which rule it was. Forgetting the evidence instead would only put the ban off until the same play reads the same way again, and it would take a forget in each of the ten watchdogs, keyed by scope, by account or by wider prefix. `TestAnUnbanLeavesTheEvidenceSoTheJuryBansAgain` pins it. - **`InspectPlayer(scope | account_id)`** answers how close the antibot is to a caller, which the `antibot ban` log line cannot: it is only written when a ban fires, so on 2026-09-14 a day of bots and no bans left nothing to read. It is `Guard.Examine`. **An account is read on the scope of its latest take** in the ledger, because the watchdogs judge scopes, with the bans on both; an account with no take inside the retention answers its bans alone and an empty `scope`. It changes nothing — no caller record is created, no watchdog is asked again, no ban is passed. It answers any running ban (`banned`, `bannedUntil`, `offence`, `flags`); per watchdog its `level` and `evidence`, aged the way the jury ages them (past `suspicionWindow` a verdict reads `clear` but keeps its evidence); `suspects` against `minSuspects` and `guilty`, what the jury would decide on a click now (the ban itself would still wait for `reflagInterval`); and the click summary the ban line carries. `tracked` false is a scope the jury has not seen inside its `trackWindow`. Parsed with `ledger.ParseCaller` and refused with `FailedPrecondition` when `antiBot.enabled` is false, as `BanPlayer` is. **A watchdog that reads `clear` has no evidence**: watchdogs only word the rule that tripped, so it says how close a caller is only once some rule has. @@ -3874,10 +3892,10 @@ There is no struct-tag validation and therefore no validator dependency — a ho - `chargeStorage.flushInterval` — how often the charges that changed are written to postgres (default 1s); also flushed on shutdown - `bonus.maxChargesPerHour` — the most charges one caller may be granted per hour (12); past it the slot is lost - `antiBot.enabled` — off registers nothing and measures nothing -- `antiBot.shadowBan.enforce` — off judges, logs and counts without dropping; the mode to deploy in -- `antiBot.shadowBan.banDurations` — the ban for each offence (the last step repeats). An offence is a ban that starts while none is running; a flag on a running ban only extends it. **Offences are never forgotten**, but for the one an operator lifts with `UnbanPlayer` +- `antiBot.bans.enforce` — off judges, logs and counts without refusing; the mode to deploy in +- `antiBot.bans.banDurations` — the ban for each offence (the last step repeats). An offence is a ban that starts while none is running; a flag on a running ban only extends it. **Offences are never forgotten**, but for the one an operator lifts with `UnbanPlayer` - `antiBot.database` — the antibot's own `cppg.Config`, `schema: antibot`; required when `antiBot.enabled`. A failed connection, migration or load refuses the boot -- `antiBot.shadowBan.reflagInterval` — how soon a banned caller can be judged again +- `antiBot.bans.reflagInterval` — how soon a banned caller can be judged again - `antiBot.jury.minSuspects` — how many watchdogs at `suspect` make a ban; one at `certain` bans alone; `stamina` and `defence` together count as one - `antiBot.jury.suspicionWindow`, `trackWindow`, `sweepInterval` — how long a verdict stands while another watchdog catches up, and how long a silent caller is remembered - `antiBot.evidence.saveInterval`, `retention` — how often every watchdog's evidence and the jury's record are written to `antibot.evidence` (1m, and on shutdown), and the oldest kept (72h) on load and in memory. See [What survives a restart](#what-survives-a-restart) diff --git a/apps/backend/cmd/api/config_test.go b/apps/backend/cmd/api/config_test.go index b088893d6..fb346c3f2 100644 --- a/apps/backend/cmd/api/config_test.go +++ b/apps/backend/cmd/api/config_test.go @@ -47,10 +47,10 @@ func TestTheExampleConfigStillReachesTheStructs(t *testing.T) { assert.Equal(t, 5*time.Minute, config.Planet.Ledger.SweepInterval) assert.Equal(t, time.Second, config.Planet.LedgerStorage.FlushInterval) - assert.False(t, config.Planet.AntiBot.ShadowBan.Enforce, "the example must ship observing only") - assert.Equal(t, []time.Duration{24 * time.Hour, 7 * 24 * time.Hour, 3 * 365 * 24 * time.Hour}, config.Planet.AntiBot.ShadowBan.BanDurations) + assert.False(t, config.Planet.AntiBot.Bans.Enforce, "the example must ship observing only") + assert.Equal(t, []time.Duration{24 * time.Hour, 7 * 24 * time.Hour, 3 * 365 * 24 * time.Hour}, config.Planet.AntiBot.Bans.BanDurations) assert.Equal(t, "antibot", config.Planet.AntiBot.Database.Schema) - assert.Equal(t, 5*time.Minute, config.Planet.AntiBot.ShadowBan.ReflagInterval) + assert.Equal(t, 5*time.Minute, config.Planet.AntiBot.Bans.ReflagInterval) assert.Equal(t, 2, config.Planet.AntiBot.Jury.MinSuspects) assert.Equal(t, 10*time.Minute, config.Planet.AntiBot.Jury.SuspicionWindow) @@ -370,7 +370,9 @@ func TestTheExampleConfigReachesTheAttachments(t *testing.T) { assert.Equal(t, "clickplanet-support", attachments.Bucket.Name) } -func TestTheProductionConfigTakesFilesInItsBucketAndNeedsItsSecret(t *testing.T) { +func productionConfig(t *testing.T) Config { + t.Helper() + const production = "../../../../deploy/vps/backend.yaml" file, err := os.ReadFile(production) require.NoError(t, err) @@ -384,6 +386,19 @@ func TestTheProductionConfigTakesFilesInItsBucketAndNeedsItsSecret(t *testing.T) var config Config require.NoError(t, cpconfigs.Load(&config, cpconfigs.FromFile(production))) + return config +} + +func TestTheProductionConfigEnforcesBans(t *testing.T) { + config := productionConfig(t) + + assert.True(t, config.Planet.AntiBot.Enabled) + assert.True(t, config.Planet.AntiBot.Bans.Enforce, "a key the config does not read leaves bans off without a word") + assert.Equal(t, []time.Duration{24 * time.Hour, 7 * 24 * time.Hour, 3 * 365 * 24 * time.Hour}, config.Planet.AntiBot.Bans.BanDurations) +} + +func TestTheProductionConfigTakesFilesInItsBucketAndNeedsItsSecret(t *testing.T) { + config := productionConfig(t) attachments := config.Support.Attachments assert.True(t, attachments.Enabled) diff --git a/apps/backend/cmd/api/example.yaml b/apps/backend/cmd/api/example.yaml index c0b592a12..1a6d657d1 100644 --- a/apps/backend/cmd/api/example.yaml +++ b/apps/backend/cmd/api/example.yaml @@ -129,7 +129,7 @@ antiBot: pool: maxOpenConns: 2 - shadowBan: + bans: enforce: false banDurations: [24h, 168h, 26280h] reflagInterval: 5m diff --git a/apps/backend/generated/proto/planet/v1/planet.pb.go b/apps/backend/generated/proto/planet/v1/planet.pb.go index 0cb1baff4..1fe050735 100644 --- a/apps/backend/generated/proto/planet/v1/planet.pb.go +++ b/apps/backend/generated/proto/planet/v1/planet.pb.go @@ -351,6 +351,51 @@ func (x *ClickResponse) GetBudget() *ClickBudget { return nil } +// The detail of a PermissionDenied that Click, DropBomb and PlaceShield answer a banned caller. +type BanRefusal struct { + state protoimpl.MessageState `protogen:"open.v1"` + BannedUntilUnixMs int64 `protobuf:"varint,1,opt,name=banned_until_unix_ms,json=bannedUntilUnixMs,proto3" json:"banned_until_unix_ms,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *BanRefusal) Reset() { + *x = BanRefusal{} + mi := &file_planet_v1_planet_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *BanRefusal) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*BanRefusal) ProtoMessage() {} + +func (x *BanRefusal) ProtoReflect() protoreflect.Message { + mi := &file_planet_v1_planet_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use BanRefusal.ProtoReflect.Descriptor instead. +func (*BanRefusal) Descriptor() ([]byte, []int) { + return file_planet_v1_planet_proto_rawDescGZIP(), []int{3} +} + +func (x *BanRefusal) GetBannedUntilUnixMs() int64 { + if x != nil { + return x.BannedUntilUnixMs + } + return 0 +} + type GetBudgetRequest struct { state protoimpl.MessageState `protogen:"open.v1"` CountryId string `protobuf:"bytes,1,opt,name=country_id,json=countryId,proto3" json:"country_id,omitempty"` @@ -360,7 +405,7 @@ type GetBudgetRequest struct { func (x *GetBudgetRequest) Reset() { *x = GetBudgetRequest{} - mi := &file_planet_v1_planet_proto_msgTypes[3] + mi := &file_planet_v1_planet_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -372,7 +417,7 @@ func (x *GetBudgetRequest) String() string { func (*GetBudgetRequest) ProtoMessage() {} func (x *GetBudgetRequest) ProtoReflect() protoreflect.Message { - mi := &file_planet_v1_planet_proto_msgTypes[3] + mi := &file_planet_v1_planet_proto_msgTypes[4] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -385,7 +430,7 @@ func (x *GetBudgetRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetBudgetRequest.ProtoReflect.Descriptor instead. func (*GetBudgetRequest) Descriptor() ([]byte, []int) { - return file_planet_v1_planet_proto_rawDescGZIP(), []int{3} + return file_planet_v1_planet_proto_rawDescGZIP(), []int{4} } func (x *GetBudgetRequest) GetCountryId() string { @@ -404,7 +449,7 @@ type GetBudgetResponse struct { func (x *GetBudgetResponse) Reset() { *x = GetBudgetResponse{} - mi := &file_planet_v1_planet_proto_msgTypes[4] + mi := &file_planet_v1_planet_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -416,7 +461,7 @@ func (x *GetBudgetResponse) String() string { func (*GetBudgetResponse) ProtoMessage() {} func (x *GetBudgetResponse) ProtoReflect() protoreflect.Message { - mi := &file_planet_v1_planet_proto_msgTypes[4] + mi := &file_planet_v1_planet_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -429,7 +474,7 @@ func (x *GetBudgetResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetBudgetResponse.ProtoReflect.Descriptor instead. func (*GetBudgetResponse) Descriptor() ([]byte, []int) { - return file_planet_v1_planet_proto_rawDescGZIP(), []int{4} + return file_planet_v1_planet_proto_rawDescGZIP(), []int{5} } func (x *GetBudgetResponse) GetBudget() *ClickBudget { @@ -447,7 +492,7 @@ type MapDensityRequest struct { func (x *MapDensityRequest) Reset() { *x = MapDensityRequest{} - mi := &file_planet_v1_planet_proto_msgTypes[5] + mi := &file_planet_v1_planet_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -459,7 +504,7 @@ func (x *MapDensityRequest) String() string { func (*MapDensityRequest) ProtoMessage() {} func (x *MapDensityRequest) ProtoReflect() protoreflect.Message { - mi := &file_planet_v1_planet_proto_msgTypes[5] + mi := &file_planet_v1_planet_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -472,7 +517,7 @@ func (x *MapDensityRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use MapDensityRequest.ProtoReflect.Descriptor instead. func (*MapDensityRequest) Descriptor() ([]byte, []int) { - return file_planet_v1_planet_proto_rawDescGZIP(), []int{5} + return file_planet_v1_planet_proto_rawDescGZIP(), []int{6} } type MapDensityResponse struct { @@ -484,7 +529,7 @@ type MapDensityResponse struct { func (x *MapDensityResponse) Reset() { *x = MapDensityResponse{} - mi := &file_planet_v1_planet_proto_msgTypes[6] + mi := &file_planet_v1_planet_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -496,7 +541,7 @@ func (x *MapDensityResponse) String() string { func (*MapDensityResponse) ProtoMessage() {} func (x *MapDensityResponse) ProtoReflect() protoreflect.Message { - mi := &file_planet_v1_planet_proto_msgTypes[6] + mi := &file_planet_v1_planet_proto_msgTypes[7] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -509,7 +554,7 @@ func (x *MapDensityResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use MapDensityResponse.ProtoReflect.Descriptor instead. func (*MapDensityResponse) Descriptor() ([]byte, []int) { - return file_planet_v1_planet_proto_rawDescGZIP(), []int{6} + return file_planet_v1_planet_proto_rawDescGZIP(), []int{7} } func (x *MapDensityResponse) GetDensity() uint32 { @@ -529,7 +574,7 @@ type GetMapRequest struct { func (x *GetMapRequest) Reset() { *x = GetMapRequest{} - mi := &file_planet_v1_planet_proto_msgTypes[7] + mi := &file_planet_v1_planet_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -541,7 +586,7 @@ func (x *GetMapRequest) String() string { func (*GetMapRequest) ProtoMessage() {} func (x *GetMapRequest) ProtoReflect() protoreflect.Message { - mi := &file_planet_v1_planet_proto_msgTypes[7] + mi := &file_planet_v1_planet_proto_msgTypes[8] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -554,7 +599,7 @@ func (x *GetMapRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetMapRequest.ProtoReflect.Descriptor instead. func (*GetMapRequest) Descriptor() ([]byte, []int) { - return file_planet_v1_planet_proto_rawDescGZIP(), []int{7} + return file_planet_v1_planet_proto_rawDescGZIP(), []int{8} } func (x *GetMapRequest) GetStartTileId() uint32 { @@ -583,7 +628,7 @@ type GetMapResponse struct { func (x *GetMapResponse) Reset() { *x = GetMapResponse{} - mi := &file_planet_v1_planet_proto_msgTypes[8] + mi := &file_planet_v1_planet_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -595,7 +640,7 @@ func (x *GetMapResponse) String() string { func (*GetMapResponse) ProtoMessage() {} func (x *GetMapResponse) ProtoReflect() protoreflect.Message { - mi := &file_planet_v1_planet_proto_msgTypes[8] + mi := &file_planet_v1_planet_proto_msgTypes[9] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -608,7 +653,7 @@ func (x *GetMapResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetMapResponse.ProtoReflect.Descriptor instead. func (*GetMapResponse) Descriptor() ([]byte, []int) { - return file_planet_v1_planet_proto_rawDescGZIP(), []int{8} + return file_planet_v1_planet_proto_rawDescGZIP(), []int{9} } func (x *GetMapResponse) GetStartTileId() uint32 { @@ -649,7 +694,7 @@ type TileShields struct { func (x *TileShields) Reset() { *x = TileShields{} - mi := &file_planet_v1_planet_proto_msgTypes[9] + mi := &file_planet_v1_planet_proto_msgTypes[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -661,7 +706,7 @@ func (x *TileShields) String() string { func (*TileShields) ProtoMessage() {} func (x *TileShields) ProtoReflect() protoreflect.Message { - mi := &file_planet_v1_planet_proto_msgTypes[9] + mi := &file_planet_v1_planet_proto_msgTypes[10] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -674,7 +719,7 @@ func (x *TileShields) ProtoReflect() protoreflect.Message { // Deprecated: Use TileShields.ProtoReflect.Descriptor instead. func (*TileShields) Descriptor() ([]byte, []int) { - return file_planet_v1_planet_proto_rawDescGZIP(), []int{9} + return file_planet_v1_planet_proto_rawDescGZIP(), []int{10} } func (x *TileShields) GetTileId() uint32 { @@ -699,7 +744,7 @@ type ListenForEventsRequest struct { func (x *ListenForEventsRequest) Reset() { *x = ListenForEventsRequest{} - mi := &file_planet_v1_planet_proto_msgTypes[10] + mi := &file_planet_v1_planet_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -711,7 +756,7 @@ func (x *ListenForEventsRequest) String() string { func (*ListenForEventsRequest) ProtoMessage() {} func (x *ListenForEventsRequest) ProtoReflect() protoreflect.Message { - mi := &file_planet_v1_planet_proto_msgTypes[10] + mi := &file_planet_v1_planet_proto_msgTypes[11] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -724,7 +769,7 @@ func (x *ListenForEventsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListenForEventsRequest.ProtoReflect.Descriptor instead. func (*ListenForEventsRequest) Descriptor() ([]byte, []int) { - return file_planet_v1_planet_proto_rawDescGZIP(), []int{10} + return file_planet_v1_planet_proto_rawDescGZIP(), []int{11} } type PlanetEvent struct { @@ -747,7 +792,7 @@ type PlanetEvent struct { func (x *PlanetEvent) Reset() { *x = PlanetEvent{} - mi := &file_planet_v1_planet_proto_msgTypes[11] + mi := &file_planet_v1_planet_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -759,7 +804,7 @@ func (x *PlanetEvent) String() string { func (*PlanetEvent) ProtoMessage() {} func (x *PlanetEvent) ProtoReflect() protoreflect.Message { - mi := &file_planet_v1_planet_proto_msgTypes[11] + mi := &file_planet_v1_planet_proto_msgTypes[12] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -772,7 +817,7 @@ func (x *PlanetEvent) ProtoReflect() protoreflect.Message { // Deprecated: Use PlanetEvent.ProtoReflect.Descriptor instead. func (*PlanetEvent) Descriptor() ([]byte, []int) { - return file_planet_v1_planet_proto_rawDescGZIP(), []int{11} + return file_planet_v1_planet_proto_rawDescGZIP(), []int{12} } func (x *PlanetEvent) GetEvent() isPlanetEvent_Event { @@ -929,7 +974,7 @@ type GetFortressesRequest struct { func (x *GetFortressesRequest) Reset() { *x = GetFortressesRequest{} - mi := &file_planet_v1_planet_proto_msgTypes[12] + mi := &file_planet_v1_planet_proto_msgTypes[13] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -941,7 +986,7 @@ func (x *GetFortressesRequest) String() string { func (*GetFortressesRequest) ProtoMessage() {} func (x *GetFortressesRequest) ProtoReflect() protoreflect.Message { - mi := &file_planet_v1_planet_proto_msgTypes[12] + mi := &file_planet_v1_planet_proto_msgTypes[13] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -954,7 +999,7 @@ func (x *GetFortressesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetFortressesRequest.ProtoReflect.Descriptor instead. func (*GetFortressesRequest) Descriptor() ([]byte, []int) { - return file_planet_v1_planet_proto_rawDescGZIP(), []int{12} + return file_planet_v1_planet_proto_rawDescGZIP(), []int{13} } // Every landmass a flag fortified last, or holds whole with nobody having fortified it. @@ -967,7 +1012,7 @@ type GetFortressesResponse struct { func (x *GetFortressesResponse) Reset() { *x = GetFortressesResponse{} - mi := &file_planet_v1_planet_proto_msgTypes[13] + mi := &file_planet_v1_planet_proto_msgTypes[14] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -979,7 +1024,7 @@ func (x *GetFortressesResponse) String() string { func (*GetFortressesResponse) ProtoMessage() {} func (x *GetFortressesResponse) ProtoReflect() protoreflect.Message { - mi := &file_planet_v1_planet_proto_msgTypes[13] + mi := &file_planet_v1_planet_proto_msgTypes[14] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -992,7 +1037,7 @@ func (x *GetFortressesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetFortressesResponse.ProtoReflect.Descriptor instead. func (*GetFortressesResponse) Descriptor() ([]byte, []int) { - return file_planet_v1_planet_proto_rawDescGZIP(), []int{13} + return file_planet_v1_planet_proto_rawDescGZIP(), []int{14} } func (x *GetFortressesResponse) GetFortresses() []*Fortress { @@ -1013,7 +1058,7 @@ type Fortress struct { func (x *Fortress) Reset() { *x = Fortress{} - mi := &file_planet_v1_planet_proto_msgTypes[14] + mi := &file_planet_v1_planet_proto_msgTypes[15] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1025,7 +1070,7 @@ func (x *Fortress) String() string { func (*Fortress) ProtoMessage() {} func (x *Fortress) ProtoReflect() protoreflect.Message { - mi := &file_planet_v1_planet_proto_msgTypes[14] + mi := &file_planet_v1_planet_proto_msgTypes[15] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1038,7 +1083,7 @@ func (x *Fortress) ProtoReflect() protoreflect.Message { // Deprecated: Use Fortress.ProtoReflect.Descriptor instead. func (*Fortress) Descriptor() ([]byte, []int) { - return file_planet_v1_planet_proto_rawDescGZIP(), []int{14} + return file_planet_v1_planet_proto_rawDescGZIP(), []int{15} } func (x *Fortress) GetLandmassId() uint32 { @@ -1069,7 +1114,7 @@ type LandmassFortified struct { func (x *LandmassFortified) Reset() { *x = LandmassFortified{} - mi := &file_planet_v1_planet_proto_msgTypes[15] + mi := &file_planet_v1_planet_proto_msgTypes[16] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1081,7 +1126,7 @@ func (x *LandmassFortified) String() string { func (*LandmassFortified) ProtoMessage() {} func (x *LandmassFortified) ProtoReflect() protoreflect.Message { - mi := &file_planet_v1_planet_proto_msgTypes[15] + mi := &file_planet_v1_planet_proto_msgTypes[16] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1094,7 +1139,7 @@ func (x *LandmassFortified) ProtoReflect() protoreflect.Message { // Deprecated: Use LandmassFortified.ProtoReflect.Descriptor instead. func (*LandmassFortified) Descriptor() ([]byte, []int) { - return file_planet_v1_planet_proto_rawDescGZIP(), []int{15} + return file_planet_v1_planet_proto_rawDescGZIP(), []int{16} } func (x *LandmassFortified) GetLandmassId() uint32 { @@ -1131,7 +1176,7 @@ type ChargesHeld struct { func (x *ChargesHeld) Reset() { *x = ChargesHeld{} - mi := &file_planet_v1_planet_proto_msgTypes[16] + mi := &file_planet_v1_planet_proto_msgTypes[17] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1143,7 +1188,7 @@ func (x *ChargesHeld) String() string { func (*ChargesHeld) ProtoMessage() {} func (x *ChargesHeld) ProtoReflect() protoreflect.Message { - mi := &file_planet_v1_planet_proto_msgTypes[16] + mi := &file_planet_v1_planet_proto_msgTypes[17] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1156,7 +1201,7 @@ func (x *ChargesHeld) ProtoReflect() protoreflect.Message { // Deprecated: Use ChargesHeld.ProtoReflect.Descriptor instead. func (*ChargesHeld) Descriptor() ([]byte, []int) { - return file_planet_v1_planet_proto_rawDescGZIP(), []int{16} + return file_planet_v1_planet_proto_rawDescGZIP(), []int{17} } func (x *ChargesHeld) GetRefill() bool { @@ -1202,7 +1247,7 @@ type GetChargesRequest struct { func (x *GetChargesRequest) Reset() { *x = GetChargesRequest{} - mi := &file_planet_v1_planet_proto_msgTypes[17] + mi := &file_planet_v1_planet_proto_msgTypes[18] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1214,7 +1259,7 @@ func (x *GetChargesRequest) String() string { func (*GetChargesRequest) ProtoMessage() {} func (x *GetChargesRequest) ProtoReflect() protoreflect.Message { - mi := &file_planet_v1_planet_proto_msgTypes[17] + mi := &file_planet_v1_planet_proto_msgTypes[18] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1227,7 +1272,7 @@ func (x *GetChargesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetChargesRequest.ProtoReflect.Descriptor instead. func (*GetChargesRequest) Descriptor() ([]byte, []int) { - return file_planet_v1_planet_proto_rawDescGZIP(), []int{17} + return file_planet_v1_planet_proto_rawDescGZIP(), []int{18} } type GetChargesResponse struct { @@ -1239,7 +1284,7 @@ type GetChargesResponse struct { func (x *GetChargesResponse) Reset() { *x = GetChargesResponse{} - mi := &file_planet_v1_planet_proto_msgTypes[18] + mi := &file_planet_v1_planet_proto_msgTypes[19] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1251,7 +1296,7 @@ func (x *GetChargesResponse) String() string { func (*GetChargesResponse) ProtoMessage() {} func (x *GetChargesResponse) ProtoReflect() protoreflect.Message { - mi := &file_planet_v1_planet_proto_msgTypes[18] + mi := &file_planet_v1_planet_proto_msgTypes[19] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1264,7 +1309,7 @@ func (x *GetChargesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetChargesResponse.ProtoReflect.Descriptor instead. func (*GetChargesResponse) Descriptor() ([]byte, []int) { - return file_planet_v1_planet_proto_rawDescGZIP(), []int{18} + return file_planet_v1_planet_proto_rawDescGZIP(), []int{19} } func (x *GetChargesResponse) GetCharges() *ChargesHeld { @@ -1282,7 +1327,7 @@ type GetBonusRulesRequest struct { func (x *GetBonusRulesRequest) Reset() { *x = GetBonusRulesRequest{} - mi := &file_planet_v1_planet_proto_msgTypes[19] + mi := &file_planet_v1_planet_proto_msgTypes[20] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1294,7 +1339,7 @@ func (x *GetBonusRulesRequest) String() string { func (*GetBonusRulesRequest) ProtoMessage() {} func (x *GetBonusRulesRequest) ProtoReflect() protoreflect.Message { - mi := &file_planet_v1_planet_proto_msgTypes[19] + mi := &file_planet_v1_planet_proto_msgTypes[20] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1307,7 +1352,7 @@ func (x *GetBonusRulesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetBonusRulesRequest.ProtoReflect.Descriptor instead. func (*GetBonusRulesRequest) Descriptor() ([]byte, []int) { - return file_planet_v1_planet_proto_rawDescGZIP(), []int{19} + return file_planet_v1_planet_proto_rawDescGZIP(), []int{20} } type GetBonusRulesResponse struct { @@ -1326,7 +1371,7 @@ type GetBonusRulesResponse struct { func (x *GetBonusRulesResponse) Reset() { *x = GetBonusRulesResponse{} - mi := &file_planet_v1_planet_proto_msgTypes[20] + mi := &file_planet_v1_planet_proto_msgTypes[21] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1338,7 +1383,7 @@ func (x *GetBonusRulesResponse) String() string { func (*GetBonusRulesResponse) ProtoMessage() {} func (x *GetBonusRulesResponse) ProtoReflect() protoreflect.Message { - mi := &file_planet_v1_planet_proto_msgTypes[20] + mi := &file_planet_v1_planet_proto_msgTypes[21] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1351,7 +1396,7 @@ func (x *GetBonusRulesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetBonusRulesResponse.ProtoReflect.Descriptor instead. func (*GetBonusRulesResponse) Descriptor() ([]byte, []int) { - return file_planet_v1_planet_proto_rawDescGZIP(), []int{20} + return file_planet_v1_planet_proto_rawDescGZIP(), []int{21} } func (x *GetBonusRulesResponse) GetBlastRadius() float64 { @@ -1413,7 +1458,7 @@ type TollStep struct { func (x *TollStep) Reset() { *x = TollStep{} - mi := &file_planet_v1_planet_proto_msgTypes[21] + mi := &file_planet_v1_planet_proto_msgTypes[22] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1425,7 +1470,7 @@ func (x *TollStep) String() string { func (*TollStep) ProtoMessage() {} func (x *TollStep) ProtoReflect() protoreflect.Message { - mi := &file_planet_v1_planet_proto_msgTypes[21] + mi := &file_planet_v1_planet_proto_msgTypes[22] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1438,7 +1483,7 @@ func (x *TollStep) ProtoReflect() protoreflect.Message { // Deprecated: Use TollStep.ProtoReflect.Descriptor instead. func (*TollStep) Descriptor() ([]byte, []int) { - return file_planet_v1_planet_proto_rawDescGZIP(), []int{21} + return file_planet_v1_planet_proto_rawDescGZIP(), []int{22} } func (x *TollStep) GetShare() float64 { @@ -1467,7 +1512,7 @@ type BonusOffered struct { func (x *BonusOffered) Reset() { *x = BonusOffered{} - mi := &file_planet_v1_planet_proto_msgTypes[22] + mi := &file_planet_v1_planet_proto_msgTypes[23] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1479,7 +1524,7 @@ func (x *BonusOffered) String() string { func (*BonusOffered) ProtoMessage() {} func (x *BonusOffered) ProtoReflect() protoreflect.Message { - mi := &file_planet_v1_planet_proto_msgTypes[22] + mi := &file_planet_v1_planet_proto_msgTypes[23] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1492,7 +1537,7 @@ func (x *BonusOffered) ProtoReflect() protoreflect.Message { // Deprecated: Use BonusOffered.ProtoReflect.Descriptor instead. func (*BonusOffered) Descriptor() ([]byte, []int) { - return file_planet_v1_planet_proto_rawDescGZIP(), []int{22} + return file_planet_v1_planet_proto_rawDescGZIP(), []int{23} } func (x *BonusOffered) GetToken() string { @@ -1534,7 +1579,7 @@ type BonusTaken struct { func (x *BonusTaken) Reset() { *x = BonusTaken{} - mi := &file_planet_v1_planet_proto_msgTypes[23] + mi := &file_planet_v1_planet_proto_msgTypes[24] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1546,7 +1591,7 @@ func (x *BonusTaken) String() string { func (*BonusTaken) ProtoMessage() {} func (x *BonusTaken) ProtoReflect() protoreflect.Message { - mi := &file_planet_v1_planet_proto_msgTypes[23] + mi := &file_planet_v1_planet_proto_msgTypes[24] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1559,7 +1604,7 @@ func (x *BonusTaken) ProtoReflect() protoreflect.Message { // Deprecated: Use BonusTaken.ProtoReflect.Descriptor instead. func (*BonusTaken) Descriptor() ([]byte, []int) { - return file_planet_v1_planet_proto_rawDescGZIP(), []int{23} + return file_planet_v1_planet_proto_rawDescGZIP(), []int{24} } func (x *BonusTaken) GetCountryId() string { @@ -1593,7 +1638,7 @@ type ClaimBonusRequest struct { func (x *ClaimBonusRequest) Reset() { *x = ClaimBonusRequest{} - mi := &file_planet_v1_planet_proto_msgTypes[24] + mi := &file_planet_v1_planet_proto_msgTypes[25] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1605,7 +1650,7 @@ func (x *ClaimBonusRequest) String() string { func (*ClaimBonusRequest) ProtoMessage() {} func (x *ClaimBonusRequest) ProtoReflect() protoreflect.Message { - mi := &file_planet_v1_planet_proto_msgTypes[24] + mi := &file_planet_v1_planet_proto_msgTypes[25] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1618,7 +1663,7 @@ func (x *ClaimBonusRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ClaimBonusRequest.ProtoReflect.Descriptor instead. func (*ClaimBonusRequest) Descriptor() ([]byte, []int) { - return file_planet_v1_planet_proto_rawDescGZIP(), []int{24} + return file_planet_v1_planet_proto_rawDescGZIP(), []int{25} } func (x *ClaimBonusRequest) GetToken() string { @@ -1646,7 +1691,7 @@ type ClaimBonusResponse struct { func (x *ClaimBonusResponse) Reset() { *x = ClaimBonusResponse{} - mi := &file_planet_v1_planet_proto_msgTypes[25] + mi := &file_planet_v1_planet_proto_msgTypes[26] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1658,7 +1703,7 @@ func (x *ClaimBonusResponse) String() string { func (*ClaimBonusResponse) ProtoMessage() {} func (x *ClaimBonusResponse) ProtoReflect() protoreflect.Message { - mi := &file_planet_v1_planet_proto_msgTypes[25] + mi := &file_planet_v1_planet_proto_msgTypes[26] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1671,7 +1716,7 @@ func (x *ClaimBonusResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ClaimBonusResponse.ProtoReflect.Descriptor instead. func (*ClaimBonusResponse) Descriptor() ([]byte, []int) { - return file_planet_v1_planet_proto_rawDescGZIP(), []int{25} + return file_planet_v1_planet_proto_rawDescGZIP(), []int{26} } func (x *ClaimBonusResponse) GetKind() BonusKind { @@ -1705,7 +1750,7 @@ type QuizOffered struct { func (x *QuizOffered) Reset() { *x = QuizOffered{} - mi := &file_planet_v1_planet_proto_msgTypes[26] + mi := &file_planet_v1_planet_proto_msgTypes[27] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1717,7 +1762,7 @@ func (x *QuizOffered) String() string { func (*QuizOffered) ProtoMessage() {} func (x *QuizOffered) ProtoReflect() protoreflect.Message { - mi := &file_planet_v1_planet_proto_msgTypes[26] + mi := &file_planet_v1_planet_proto_msgTypes[27] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1730,7 +1775,7 @@ func (x *QuizOffered) ProtoReflect() protoreflect.Message { // Deprecated: Use QuizOffered.ProtoReflect.Descriptor instead. func (*QuizOffered) Descriptor() ([]byte, []int) { - return file_planet_v1_planet_proto_rawDescGZIP(), []int{26} + return file_planet_v1_planet_proto_rawDescGZIP(), []int{27} } func (x *QuizOffered) GetToken() string { @@ -1756,7 +1801,7 @@ type OpenQuizRequest struct { func (x *OpenQuizRequest) Reset() { *x = OpenQuizRequest{} - mi := &file_planet_v1_planet_proto_msgTypes[27] + mi := &file_planet_v1_planet_proto_msgTypes[28] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1768,7 +1813,7 @@ func (x *OpenQuizRequest) String() string { func (*OpenQuizRequest) ProtoMessage() {} func (x *OpenQuizRequest) ProtoReflect() protoreflect.Message { - mi := &file_planet_v1_planet_proto_msgTypes[27] + mi := &file_planet_v1_planet_proto_msgTypes[28] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1781,7 +1826,7 @@ func (x *OpenQuizRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use OpenQuizRequest.ProtoReflect.Descriptor instead. func (*OpenQuizRequest) Descriptor() ([]byte, []int) { - return file_planet_v1_planet_proto_rawDescGZIP(), []int{27} + return file_planet_v1_planet_proto_rawDescGZIP(), []int{28} } func (x *OpenQuizRequest) GetToken() string { @@ -1803,7 +1848,7 @@ type OpenQuizResponse struct { func (x *OpenQuizResponse) Reset() { *x = OpenQuizResponse{} - mi := &file_planet_v1_planet_proto_msgTypes[28] + mi := &file_planet_v1_planet_proto_msgTypes[29] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1815,7 +1860,7 @@ func (x *OpenQuizResponse) String() string { func (*OpenQuizResponse) ProtoMessage() {} func (x *OpenQuizResponse) ProtoReflect() protoreflect.Message { - mi := &file_planet_v1_planet_proto_msgTypes[28] + mi := &file_planet_v1_planet_proto_msgTypes[29] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1828,7 +1873,7 @@ func (x *OpenQuizResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use OpenQuizResponse.ProtoReflect.Descriptor instead. func (*OpenQuizResponse) Descriptor() ([]byte, []int) { - return file_planet_v1_planet_proto_rawDescGZIP(), []int{28} + return file_planet_v1_planet_proto_rawDescGZIP(), []int{29} } func (x *OpenQuizResponse) GetQuestion() string { @@ -1870,7 +1915,7 @@ type AnswerQuizRequest struct { func (x *AnswerQuizRequest) Reset() { *x = AnswerQuizRequest{} - mi := &file_planet_v1_planet_proto_msgTypes[29] + mi := &file_planet_v1_planet_proto_msgTypes[30] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1882,7 +1927,7 @@ func (x *AnswerQuizRequest) String() string { func (*AnswerQuizRequest) ProtoMessage() {} func (x *AnswerQuizRequest) ProtoReflect() protoreflect.Message { - mi := &file_planet_v1_planet_proto_msgTypes[29] + mi := &file_planet_v1_planet_proto_msgTypes[30] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1895,7 +1940,7 @@ func (x *AnswerQuizRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use AnswerQuizRequest.ProtoReflect.Descriptor instead. func (*AnswerQuizRequest) Descriptor() ([]byte, []int) { - return file_planet_v1_planet_proto_rawDescGZIP(), []int{29} + return file_planet_v1_planet_proto_rawDescGZIP(), []int{30} } func (x *AnswerQuizRequest) GetToken() string { @@ -1932,7 +1977,7 @@ type AnswerQuizResponse struct { func (x *AnswerQuizResponse) Reset() { *x = AnswerQuizResponse{} - mi := &file_planet_v1_planet_proto_msgTypes[30] + mi := &file_planet_v1_planet_proto_msgTypes[31] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1944,7 +1989,7 @@ func (x *AnswerQuizResponse) String() string { func (*AnswerQuizResponse) ProtoMessage() {} func (x *AnswerQuizResponse) ProtoReflect() protoreflect.Message { - mi := &file_planet_v1_planet_proto_msgTypes[30] + mi := &file_planet_v1_planet_proto_msgTypes[31] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1957,7 +2002,7 @@ func (x *AnswerQuizResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use AnswerQuizResponse.ProtoReflect.Descriptor instead. func (*AnswerQuizResponse) Descriptor() ([]byte, []int) { - return file_planet_v1_planet_proto_rawDescGZIP(), []int{30} + return file_planet_v1_planet_proto_rawDescGZIP(), []int{31} } func (x *AnswerQuizResponse) GetCorrect() bool { @@ -2004,7 +2049,7 @@ type UseRefillRequest struct { func (x *UseRefillRequest) Reset() { *x = UseRefillRequest{} - mi := &file_planet_v1_planet_proto_msgTypes[31] + mi := &file_planet_v1_planet_proto_msgTypes[32] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2016,7 +2061,7 @@ func (x *UseRefillRequest) String() string { func (*UseRefillRequest) ProtoMessage() {} func (x *UseRefillRequest) ProtoReflect() protoreflect.Message { - mi := &file_planet_v1_planet_proto_msgTypes[31] + mi := &file_planet_v1_planet_proto_msgTypes[32] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2029,7 +2074,7 @@ func (x *UseRefillRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use UseRefillRequest.ProtoReflect.Descriptor instead. func (*UseRefillRequest) Descriptor() ([]byte, []int) { - return file_planet_v1_planet_proto_rawDescGZIP(), []int{31} + return file_planet_v1_planet_proto_rawDescGZIP(), []int{32} } func (x *UseRefillRequest) GetCountryId() string { @@ -2049,7 +2094,7 @@ type UseRefillResponse struct { func (x *UseRefillResponse) Reset() { *x = UseRefillResponse{} - mi := &file_planet_v1_planet_proto_msgTypes[32] + mi := &file_planet_v1_planet_proto_msgTypes[33] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2061,7 +2106,7 @@ func (x *UseRefillResponse) String() string { func (*UseRefillResponse) ProtoMessage() {} func (x *UseRefillResponse) ProtoReflect() protoreflect.Message { - mi := &file_planet_v1_planet_proto_msgTypes[32] + mi := &file_planet_v1_planet_proto_msgTypes[33] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2074,7 +2119,7 @@ func (x *UseRefillResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use UseRefillResponse.ProtoReflect.Descriptor instead. func (*UseRefillResponse) Descriptor() ([]byte, []int) { - return file_planet_v1_planet_proto_rawDescGZIP(), []int{32} + return file_planet_v1_planet_proto_rawDescGZIP(), []int{33} } func (x *UseRefillResponse) GetBudget() *ClickBudget { @@ -2102,7 +2147,7 @@ type GlobePoint struct { func (x *GlobePoint) Reset() { *x = GlobePoint{} - mi := &file_planet_v1_planet_proto_msgTypes[33] + mi := &file_planet_v1_planet_proto_msgTypes[34] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2114,7 +2159,7 @@ func (x *GlobePoint) String() string { func (*GlobePoint) ProtoMessage() {} func (x *GlobePoint) ProtoReflect() protoreflect.Message { - mi := &file_planet_v1_planet_proto_msgTypes[33] + mi := &file_planet_v1_planet_proto_msgTypes[34] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2127,7 +2172,7 @@ func (x *GlobePoint) ProtoReflect() protoreflect.Message { // Deprecated: Use GlobePoint.ProtoReflect.Descriptor instead. func (*GlobePoint) Descriptor() ([]byte, []int) { - return file_planet_v1_planet_proto_rawDescGZIP(), []int{33} + return file_planet_v1_planet_proto_rawDescGZIP(), []int{34} } func (x *GlobePoint) GetX() float64 { @@ -2161,7 +2206,7 @@ type DropBombRequest struct { func (x *DropBombRequest) Reset() { *x = DropBombRequest{} - mi := &file_planet_v1_planet_proto_msgTypes[34] + mi := &file_planet_v1_planet_proto_msgTypes[35] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2173,7 +2218,7 @@ func (x *DropBombRequest) String() string { func (*DropBombRequest) ProtoMessage() {} func (x *DropBombRequest) ProtoReflect() protoreflect.Message { - mi := &file_planet_v1_planet_proto_msgTypes[34] + mi := &file_planet_v1_planet_proto_msgTypes[35] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2186,7 +2231,7 @@ func (x *DropBombRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use DropBombRequest.ProtoReflect.Descriptor instead. func (*DropBombRequest) Descriptor() ([]byte, []int) { - return file_planet_v1_planet_proto_rawDescGZIP(), []int{34} + return file_planet_v1_planet_proto_rawDescGZIP(), []int{35} } func (x *DropBombRequest) GetTarget() *GlobePoint { @@ -2211,7 +2256,7 @@ type DropBombResponse struct { func (x *DropBombResponse) Reset() { *x = DropBombResponse{} - mi := &file_planet_v1_planet_proto_msgTypes[35] + mi := &file_planet_v1_planet_proto_msgTypes[36] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2223,7 +2268,7 @@ func (x *DropBombResponse) String() string { func (*DropBombResponse) ProtoMessage() {} func (x *DropBombResponse) ProtoReflect() protoreflect.Message { - mi := &file_planet_v1_planet_proto_msgTypes[35] + mi := &file_planet_v1_planet_proto_msgTypes[36] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2236,7 +2281,7 @@ func (x *DropBombResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use DropBombResponse.ProtoReflect.Descriptor instead. func (*DropBombResponse) Descriptor() ([]byte, []int) { - return file_planet_v1_planet_proto_rawDescGZIP(), []int{35} + return file_planet_v1_planet_proto_rawDescGZIP(), []int{36} } type BombDropped struct { @@ -2254,7 +2299,7 @@ type BombDropped struct { func (x *BombDropped) Reset() { *x = BombDropped{} - mi := &file_planet_v1_planet_proto_msgTypes[36] + mi := &file_planet_v1_planet_proto_msgTypes[37] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2266,7 +2311,7 @@ func (x *BombDropped) String() string { func (*BombDropped) ProtoMessage() {} func (x *BombDropped) ProtoReflect() protoreflect.Message { - mi := &file_planet_v1_planet_proto_msgTypes[36] + mi := &file_planet_v1_planet_proto_msgTypes[37] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2279,7 +2324,7 @@ func (x *BombDropped) ProtoReflect() protoreflect.Message { // Deprecated: Use BombDropped.ProtoReflect.Descriptor instead. func (*BombDropped) Descriptor() ([]byte, []int) { - return file_planet_v1_planet_proto_rawDescGZIP(), []int{36} + return file_planet_v1_planet_proto_rawDescGZIP(), []int{37} } func (x *BombDropped) GetTileId() uint32 { @@ -2337,7 +2382,7 @@ type TilesEnclosed struct { func (x *TilesEnclosed) Reset() { *x = TilesEnclosed{} - mi := &file_planet_v1_planet_proto_msgTypes[37] + mi := &file_planet_v1_planet_proto_msgTypes[38] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2349,7 +2394,7 @@ func (x *TilesEnclosed) String() string { func (*TilesEnclosed) ProtoMessage() {} func (x *TilesEnclosed) ProtoReflect() protoreflect.Message { - mi := &file_planet_v1_planet_proto_msgTypes[37] + mi := &file_planet_v1_planet_proto_msgTypes[38] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2362,7 +2407,7 @@ func (x *TilesEnclosed) ProtoReflect() protoreflect.Message { // Deprecated: Use TilesEnclosed.ProtoReflect.Descriptor instead. func (*TilesEnclosed) Descriptor() ([]byte, []int) { - return file_planet_v1_planet_proto_rawDescGZIP(), []int{37} + return file_planet_v1_planet_proto_rawDescGZIP(), []int{38} } func (x *TilesEnclosed) GetCountryId() string { @@ -2411,7 +2456,7 @@ type TilesSpread struct { func (x *TilesSpread) Reset() { *x = TilesSpread{} - mi := &file_planet_v1_planet_proto_msgTypes[38] + mi := &file_planet_v1_planet_proto_msgTypes[39] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2423,7 +2468,7 @@ func (x *TilesSpread) String() string { func (*TilesSpread) ProtoMessage() {} func (x *TilesSpread) ProtoReflect() protoreflect.Message { - mi := &file_planet_v1_planet_proto_msgTypes[38] + mi := &file_planet_v1_planet_proto_msgTypes[39] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2436,7 +2481,7 @@ func (x *TilesSpread) ProtoReflect() protoreflect.Message { // Deprecated: Use TilesSpread.ProtoReflect.Descriptor instead. func (*TilesSpread) Descriptor() ([]byte, []int) { - return file_planet_v1_planet_proto_rawDescGZIP(), []int{38} + return file_planet_v1_planet_proto_rawDescGZIP(), []int{39} } func (x *TilesSpread) GetCountryId() string { @@ -2470,7 +2515,7 @@ type PlaceShieldRequest struct { func (x *PlaceShieldRequest) Reset() { *x = PlaceShieldRequest{} - mi := &file_planet_v1_planet_proto_msgTypes[39] + mi := &file_planet_v1_planet_proto_msgTypes[40] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2482,7 +2527,7 @@ func (x *PlaceShieldRequest) String() string { func (*PlaceShieldRequest) ProtoMessage() {} func (x *PlaceShieldRequest) ProtoReflect() protoreflect.Message { - mi := &file_planet_v1_planet_proto_msgTypes[39] + mi := &file_planet_v1_planet_proto_msgTypes[40] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2495,7 +2540,7 @@ func (x *PlaceShieldRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use PlaceShieldRequest.ProtoReflect.Descriptor instead. func (*PlaceShieldRequest) Descriptor() ([]byte, []int) { - return file_planet_v1_planet_proto_rawDescGZIP(), []int{39} + return file_planet_v1_planet_proto_rawDescGZIP(), []int{40} } func (x *PlaceShieldRequest) GetTileId() uint32 { @@ -2521,7 +2566,7 @@ type PlaceShieldResponse struct { func (x *PlaceShieldResponse) Reset() { *x = PlaceShieldResponse{} - mi := &file_planet_v1_planet_proto_msgTypes[40] + mi := &file_planet_v1_planet_proto_msgTypes[41] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2533,7 +2578,7 @@ func (x *PlaceShieldResponse) String() string { func (*PlaceShieldResponse) ProtoMessage() {} func (x *PlaceShieldResponse) ProtoReflect() protoreflect.Message { - mi := &file_planet_v1_planet_proto_msgTypes[40] + mi := &file_planet_v1_planet_proto_msgTypes[41] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2546,7 +2591,7 @@ func (x *PlaceShieldResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use PlaceShieldResponse.ProtoReflect.Descriptor instead. func (*PlaceShieldResponse) Descriptor() ([]byte, []int) { - return file_planet_v1_planet_proto_rawDescGZIP(), []int{40} + return file_planet_v1_planet_proto_rawDescGZIP(), []int{41} } func (x *PlaceShieldResponse) GetCharges() *ChargesHeld { @@ -2564,7 +2609,7 @@ type Heartbeat struct { func (x *Heartbeat) Reset() { *x = Heartbeat{} - mi := &file_planet_v1_planet_proto_msgTypes[41] + mi := &file_planet_v1_planet_proto_msgTypes[42] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2576,7 +2621,7 @@ func (x *Heartbeat) String() string { func (*Heartbeat) ProtoMessage() {} func (x *Heartbeat) ProtoReflect() protoreflect.Message { - mi := &file_planet_v1_planet_proto_msgTypes[41] + mi := &file_planet_v1_planet_proto_msgTypes[42] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2589,7 +2634,7 @@ func (x *Heartbeat) ProtoReflect() protoreflect.Message { // Deprecated: Use Heartbeat.ProtoReflect.Descriptor instead. func (*Heartbeat) Descriptor() ([]byte, []int) { - return file_planet_v1_planet_proto_rawDescGZIP(), []int{41} + return file_planet_v1_planet_proto_rawDescGZIP(), []int{42} } type TileUpdate struct { @@ -2607,7 +2652,7 @@ type TileUpdate struct { func (x *TileUpdate) Reset() { *x = TileUpdate{} - mi := &file_planet_v1_planet_proto_msgTypes[42] + mi := &file_planet_v1_planet_proto_msgTypes[43] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2619,7 +2664,7 @@ func (x *TileUpdate) String() string { func (*TileUpdate) ProtoMessage() {} func (x *TileUpdate) ProtoReflect() protoreflect.Message { - mi := &file_planet_v1_planet_proto_msgTypes[42] + mi := &file_planet_v1_planet_proto_msgTypes[43] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2632,7 +2677,7 @@ func (x *TileUpdate) ProtoReflect() protoreflect.Message { // Deprecated: Use TileUpdate.ProtoReflect.Descriptor instead. func (*TileUpdate) Descriptor() ([]byte, []int) { - return file_planet_v1_planet_proto_rawDescGZIP(), []int{42} + return file_planet_v1_planet_proto_rawDescGZIP(), []int{43} } func (x *TileUpdate) GetTileId() uint32 { @@ -2695,7 +2740,10 @@ const file_planet_v1_planet_proto_rawDesc = "" + "\x06spread\x18\x03 \x01(\bR\x06spread\x12\x18\n" + "\aenclose\x18\x04 \x01(\bR\aenclose\"?\n" + "\rClickResponse\x12.\n" + - "\x06budget\x18\x01 \x01(\v2\x16.planet.v1.ClickBudgetR\x06budget\"1\n" + + "\x06budget\x18\x01 \x01(\v2\x16.planet.v1.ClickBudgetR\x06budget\"=\n" + + "\n" + + "BanRefusal\x12/\n" + + "\x14banned_until_unix_ms\x18\x01 \x01(\x03R\x11bannedUntilUnixMs\"1\n" + "\x10GetBudgetRequest\x12\x1d\n" + "\n" + "country_id\x18\x01 \x01(\tR\tcountryId\"C\n" + @@ -2910,110 +2958,111 @@ func file_planet_v1_planet_proto_rawDescGZIP() []byte { } var file_planet_v1_planet_proto_enumTypes = make([]protoimpl.EnumInfo, 2) -var file_planet_v1_planet_proto_msgTypes = make([]protoimpl.MessageInfo, 43) +var file_planet_v1_planet_proto_msgTypes = make([]protoimpl.MessageInfo, 44) var file_planet_v1_planet_proto_goTypes = []any{ (SharedWith)(0), // 0: planet.v1.SharedWith (BonusKind)(0), // 1: planet.v1.BonusKind (*ClickBudget)(nil), // 2: planet.v1.ClickBudget (*ClickRequest)(nil), // 3: planet.v1.ClickRequest (*ClickResponse)(nil), // 4: planet.v1.ClickResponse - (*GetBudgetRequest)(nil), // 5: planet.v1.GetBudgetRequest - (*GetBudgetResponse)(nil), // 6: planet.v1.GetBudgetResponse - (*MapDensityRequest)(nil), // 7: planet.v1.MapDensityRequest - (*MapDensityResponse)(nil), // 8: planet.v1.MapDensityResponse - (*GetMapRequest)(nil), // 9: planet.v1.GetMapRequest - (*GetMapResponse)(nil), // 10: planet.v1.GetMapResponse - (*TileShields)(nil), // 11: planet.v1.TileShields - (*ListenForEventsRequest)(nil), // 12: planet.v1.ListenForEventsRequest - (*PlanetEvent)(nil), // 13: planet.v1.PlanetEvent - (*GetFortressesRequest)(nil), // 14: planet.v1.GetFortressesRequest - (*GetFortressesResponse)(nil), // 15: planet.v1.GetFortressesResponse - (*Fortress)(nil), // 16: planet.v1.Fortress - (*LandmassFortified)(nil), // 17: planet.v1.LandmassFortified - (*ChargesHeld)(nil), // 18: planet.v1.ChargesHeld - (*GetChargesRequest)(nil), // 19: planet.v1.GetChargesRequest - (*GetChargesResponse)(nil), // 20: planet.v1.GetChargesResponse - (*GetBonusRulesRequest)(nil), // 21: planet.v1.GetBonusRulesRequest - (*GetBonusRulesResponse)(nil), // 22: planet.v1.GetBonusRulesResponse - (*TollStep)(nil), // 23: planet.v1.TollStep - (*BonusOffered)(nil), // 24: planet.v1.BonusOffered - (*BonusTaken)(nil), // 25: planet.v1.BonusTaken - (*ClaimBonusRequest)(nil), // 26: planet.v1.ClaimBonusRequest - (*ClaimBonusResponse)(nil), // 27: planet.v1.ClaimBonusResponse - (*QuizOffered)(nil), // 28: planet.v1.QuizOffered - (*OpenQuizRequest)(nil), // 29: planet.v1.OpenQuizRequest - (*OpenQuizResponse)(nil), // 30: planet.v1.OpenQuizResponse - (*AnswerQuizRequest)(nil), // 31: planet.v1.AnswerQuizRequest - (*AnswerQuizResponse)(nil), // 32: planet.v1.AnswerQuizResponse - (*UseRefillRequest)(nil), // 33: planet.v1.UseRefillRequest - (*UseRefillResponse)(nil), // 34: planet.v1.UseRefillResponse - (*GlobePoint)(nil), // 35: planet.v1.GlobePoint - (*DropBombRequest)(nil), // 36: planet.v1.DropBombRequest - (*DropBombResponse)(nil), // 37: planet.v1.DropBombResponse - (*BombDropped)(nil), // 38: planet.v1.BombDropped - (*TilesEnclosed)(nil), // 39: planet.v1.TilesEnclosed - (*TilesSpread)(nil), // 40: planet.v1.TilesSpread - (*PlaceShieldRequest)(nil), // 41: planet.v1.PlaceShieldRequest - (*PlaceShieldResponse)(nil), // 42: planet.v1.PlaceShieldResponse - (*Heartbeat)(nil), // 43: planet.v1.Heartbeat - (*TileUpdate)(nil), // 44: planet.v1.TileUpdate + (*BanRefusal)(nil), // 5: planet.v1.BanRefusal + (*GetBudgetRequest)(nil), // 6: planet.v1.GetBudgetRequest + (*GetBudgetResponse)(nil), // 7: planet.v1.GetBudgetResponse + (*MapDensityRequest)(nil), // 8: planet.v1.MapDensityRequest + (*MapDensityResponse)(nil), // 9: planet.v1.MapDensityResponse + (*GetMapRequest)(nil), // 10: planet.v1.GetMapRequest + (*GetMapResponse)(nil), // 11: planet.v1.GetMapResponse + (*TileShields)(nil), // 12: planet.v1.TileShields + (*ListenForEventsRequest)(nil), // 13: planet.v1.ListenForEventsRequest + (*PlanetEvent)(nil), // 14: planet.v1.PlanetEvent + (*GetFortressesRequest)(nil), // 15: planet.v1.GetFortressesRequest + (*GetFortressesResponse)(nil), // 16: planet.v1.GetFortressesResponse + (*Fortress)(nil), // 17: planet.v1.Fortress + (*LandmassFortified)(nil), // 18: planet.v1.LandmassFortified + (*ChargesHeld)(nil), // 19: planet.v1.ChargesHeld + (*GetChargesRequest)(nil), // 20: planet.v1.GetChargesRequest + (*GetChargesResponse)(nil), // 21: planet.v1.GetChargesResponse + (*GetBonusRulesRequest)(nil), // 22: planet.v1.GetBonusRulesRequest + (*GetBonusRulesResponse)(nil), // 23: planet.v1.GetBonusRulesResponse + (*TollStep)(nil), // 24: planet.v1.TollStep + (*BonusOffered)(nil), // 25: planet.v1.BonusOffered + (*BonusTaken)(nil), // 26: planet.v1.BonusTaken + (*ClaimBonusRequest)(nil), // 27: planet.v1.ClaimBonusRequest + (*ClaimBonusResponse)(nil), // 28: planet.v1.ClaimBonusResponse + (*QuizOffered)(nil), // 29: planet.v1.QuizOffered + (*OpenQuizRequest)(nil), // 30: planet.v1.OpenQuizRequest + (*OpenQuizResponse)(nil), // 31: planet.v1.OpenQuizResponse + (*AnswerQuizRequest)(nil), // 32: planet.v1.AnswerQuizRequest + (*AnswerQuizResponse)(nil), // 33: planet.v1.AnswerQuizResponse + (*UseRefillRequest)(nil), // 34: planet.v1.UseRefillRequest + (*UseRefillResponse)(nil), // 35: planet.v1.UseRefillResponse + (*GlobePoint)(nil), // 36: planet.v1.GlobePoint + (*DropBombRequest)(nil), // 37: planet.v1.DropBombRequest + (*DropBombResponse)(nil), // 38: planet.v1.DropBombResponse + (*BombDropped)(nil), // 39: planet.v1.BombDropped + (*TilesEnclosed)(nil), // 40: planet.v1.TilesEnclosed + (*TilesSpread)(nil), // 41: planet.v1.TilesSpread + (*PlaceShieldRequest)(nil), // 42: planet.v1.PlaceShieldRequest + (*PlaceShieldResponse)(nil), // 43: planet.v1.PlaceShieldResponse + (*Heartbeat)(nil), // 44: planet.v1.Heartbeat + (*TileUpdate)(nil), // 45: planet.v1.TileUpdate } var file_planet_v1_planet_proto_depIdxs = []int32{ 0, // 0: planet.v1.ClickBudget.shared_with:type_name -> planet.v1.SharedWith 2, // 1: planet.v1.ClickResponse.budget:type_name -> planet.v1.ClickBudget 2, // 2: planet.v1.GetBudgetResponse.budget:type_name -> planet.v1.ClickBudget - 11, // 3: planet.v1.GetMapResponse.shields:type_name -> planet.v1.TileShields - 44, // 4: planet.v1.PlanetEvent.tile_update:type_name -> planet.v1.TileUpdate - 43, // 5: planet.v1.PlanetEvent.heartbeat:type_name -> planet.v1.Heartbeat - 24, // 6: planet.v1.PlanetEvent.bonus_offered:type_name -> planet.v1.BonusOffered - 25, // 7: planet.v1.PlanetEvent.bonus_taken:type_name -> planet.v1.BonusTaken - 38, // 8: planet.v1.PlanetEvent.bomb_dropped:type_name -> planet.v1.BombDropped - 39, // 9: planet.v1.PlanetEvent.tiles_enclosed:type_name -> planet.v1.TilesEnclosed - 40, // 10: planet.v1.PlanetEvent.tiles_spread:type_name -> planet.v1.TilesSpread - 28, // 11: planet.v1.PlanetEvent.quiz_offered:type_name -> planet.v1.QuizOffered - 17, // 12: planet.v1.PlanetEvent.landmass_fortified:type_name -> planet.v1.LandmassFortified - 16, // 13: planet.v1.GetFortressesResponse.fortresses:type_name -> planet.v1.Fortress - 18, // 14: planet.v1.GetChargesResponse.charges:type_name -> planet.v1.ChargesHeld - 23, // 15: planet.v1.GetBonusRulesResponse.toll_steps:type_name -> planet.v1.TollStep + 12, // 3: planet.v1.GetMapResponse.shields:type_name -> planet.v1.TileShields + 45, // 4: planet.v1.PlanetEvent.tile_update:type_name -> planet.v1.TileUpdate + 44, // 5: planet.v1.PlanetEvent.heartbeat:type_name -> planet.v1.Heartbeat + 25, // 6: planet.v1.PlanetEvent.bonus_offered:type_name -> planet.v1.BonusOffered + 26, // 7: planet.v1.PlanetEvent.bonus_taken:type_name -> planet.v1.BonusTaken + 39, // 8: planet.v1.PlanetEvent.bomb_dropped:type_name -> planet.v1.BombDropped + 40, // 9: planet.v1.PlanetEvent.tiles_enclosed:type_name -> planet.v1.TilesEnclosed + 41, // 10: planet.v1.PlanetEvent.tiles_spread:type_name -> planet.v1.TilesSpread + 29, // 11: planet.v1.PlanetEvent.quiz_offered:type_name -> planet.v1.QuizOffered + 18, // 12: planet.v1.PlanetEvent.landmass_fortified:type_name -> planet.v1.LandmassFortified + 17, // 13: planet.v1.GetFortressesResponse.fortresses:type_name -> planet.v1.Fortress + 19, // 14: planet.v1.GetChargesResponse.charges:type_name -> planet.v1.ChargesHeld + 24, // 15: planet.v1.GetBonusRulesResponse.toll_steps:type_name -> planet.v1.TollStep 1, // 16: planet.v1.BonusOffered.kind:type_name -> planet.v1.BonusKind 1, // 17: planet.v1.BonusTaken.kind:type_name -> planet.v1.BonusKind 1, // 18: planet.v1.ClaimBonusResponse.kind:type_name -> planet.v1.BonusKind - 18, // 19: planet.v1.ClaimBonusResponse.charges:type_name -> planet.v1.ChargesHeld + 19, // 19: planet.v1.ClaimBonusResponse.charges:type_name -> planet.v1.ChargesHeld 1, // 20: planet.v1.AnswerQuizResponse.kind:type_name -> planet.v1.BonusKind - 18, // 21: planet.v1.AnswerQuizResponse.charges:type_name -> planet.v1.ChargesHeld + 19, // 21: planet.v1.AnswerQuizResponse.charges:type_name -> planet.v1.ChargesHeld 2, // 22: planet.v1.UseRefillResponse.budget:type_name -> planet.v1.ClickBudget - 18, // 23: planet.v1.UseRefillResponse.charges:type_name -> planet.v1.ChargesHeld - 35, // 24: planet.v1.DropBombRequest.target:type_name -> planet.v1.GlobePoint - 35, // 25: planet.v1.BombDropped.point:type_name -> planet.v1.GlobePoint - 18, // 26: planet.v1.PlaceShieldResponse.charges:type_name -> planet.v1.ChargesHeld + 19, // 23: planet.v1.UseRefillResponse.charges:type_name -> planet.v1.ChargesHeld + 36, // 24: planet.v1.DropBombRequest.target:type_name -> planet.v1.GlobePoint + 36, // 25: planet.v1.BombDropped.point:type_name -> planet.v1.GlobePoint + 19, // 26: planet.v1.PlaceShieldResponse.charges:type_name -> planet.v1.ChargesHeld 3, // 27: planet.v1.ClickService.Click:input_type -> planet.v1.ClickRequest - 5, // 28: planet.v1.ClickService.GetBudget:input_type -> planet.v1.GetBudgetRequest - 7, // 29: planet.v1.ClickService.MapDensity:input_type -> planet.v1.MapDensityRequest - 9, // 30: planet.v1.ClickService.GetMap:input_type -> planet.v1.GetMapRequest - 12, // 31: planet.v1.ClickService.ListenForEvents:input_type -> planet.v1.ListenForEventsRequest - 26, // 32: planet.v1.ClickService.ClaimBonus:input_type -> planet.v1.ClaimBonusRequest - 36, // 33: planet.v1.ClickService.DropBomb:input_type -> planet.v1.DropBombRequest - 33, // 34: planet.v1.ClickService.UseRefill:input_type -> planet.v1.UseRefillRequest - 19, // 35: planet.v1.ClickService.GetCharges:input_type -> planet.v1.GetChargesRequest - 21, // 36: planet.v1.ClickService.GetBonusRules:input_type -> planet.v1.GetBonusRulesRequest - 29, // 37: planet.v1.ClickService.OpenQuiz:input_type -> planet.v1.OpenQuizRequest - 31, // 38: planet.v1.ClickService.AnswerQuiz:input_type -> planet.v1.AnswerQuizRequest - 41, // 39: planet.v1.ClickService.PlaceShield:input_type -> planet.v1.PlaceShieldRequest - 14, // 40: planet.v1.ClickService.GetFortresses:input_type -> planet.v1.GetFortressesRequest + 6, // 28: planet.v1.ClickService.GetBudget:input_type -> planet.v1.GetBudgetRequest + 8, // 29: planet.v1.ClickService.MapDensity:input_type -> planet.v1.MapDensityRequest + 10, // 30: planet.v1.ClickService.GetMap:input_type -> planet.v1.GetMapRequest + 13, // 31: planet.v1.ClickService.ListenForEvents:input_type -> planet.v1.ListenForEventsRequest + 27, // 32: planet.v1.ClickService.ClaimBonus:input_type -> planet.v1.ClaimBonusRequest + 37, // 33: planet.v1.ClickService.DropBomb:input_type -> planet.v1.DropBombRequest + 34, // 34: planet.v1.ClickService.UseRefill:input_type -> planet.v1.UseRefillRequest + 20, // 35: planet.v1.ClickService.GetCharges:input_type -> planet.v1.GetChargesRequest + 22, // 36: planet.v1.ClickService.GetBonusRules:input_type -> planet.v1.GetBonusRulesRequest + 30, // 37: planet.v1.ClickService.OpenQuiz:input_type -> planet.v1.OpenQuizRequest + 32, // 38: planet.v1.ClickService.AnswerQuiz:input_type -> planet.v1.AnswerQuizRequest + 42, // 39: planet.v1.ClickService.PlaceShield:input_type -> planet.v1.PlaceShieldRequest + 15, // 40: planet.v1.ClickService.GetFortresses:input_type -> planet.v1.GetFortressesRequest 4, // 41: planet.v1.ClickService.Click:output_type -> planet.v1.ClickResponse - 6, // 42: planet.v1.ClickService.GetBudget:output_type -> planet.v1.GetBudgetResponse - 8, // 43: planet.v1.ClickService.MapDensity:output_type -> planet.v1.MapDensityResponse - 10, // 44: planet.v1.ClickService.GetMap:output_type -> planet.v1.GetMapResponse - 13, // 45: planet.v1.ClickService.ListenForEvents:output_type -> planet.v1.PlanetEvent - 27, // 46: planet.v1.ClickService.ClaimBonus:output_type -> planet.v1.ClaimBonusResponse - 37, // 47: planet.v1.ClickService.DropBomb:output_type -> planet.v1.DropBombResponse - 34, // 48: planet.v1.ClickService.UseRefill:output_type -> planet.v1.UseRefillResponse - 20, // 49: planet.v1.ClickService.GetCharges:output_type -> planet.v1.GetChargesResponse - 22, // 50: planet.v1.ClickService.GetBonusRules:output_type -> planet.v1.GetBonusRulesResponse - 30, // 51: planet.v1.ClickService.OpenQuiz:output_type -> planet.v1.OpenQuizResponse - 32, // 52: planet.v1.ClickService.AnswerQuiz:output_type -> planet.v1.AnswerQuizResponse - 42, // 53: planet.v1.ClickService.PlaceShield:output_type -> planet.v1.PlaceShieldResponse - 15, // 54: planet.v1.ClickService.GetFortresses:output_type -> planet.v1.GetFortressesResponse + 7, // 42: planet.v1.ClickService.GetBudget:output_type -> planet.v1.GetBudgetResponse + 9, // 43: planet.v1.ClickService.MapDensity:output_type -> planet.v1.MapDensityResponse + 11, // 44: planet.v1.ClickService.GetMap:output_type -> planet.v1.GetMapResponse + 14, // 45: planet.v1.ClickService.ListenForEvents:output_type -> planet.v1.PlanetEvent + 28, // 46: planet.v1.ClickService.ClaimBonus:output_type -> planet.v1.ClaimBonusResponse + 38, // 47: planet.v1.ClickService.DropBomb:output_type -> planet.v1.DropBombResponse + 35, // 48: planet.v1.ClickService.UseRefill:output_type -> planet.v1.UseRefillResponse + 21, // 49: planet.v1.ClickService.GetCharges:output_type -> planet.v1.GetChargesResponse + 23, // 50: planet.v1.ClickService.GetBonusRules:output_type -> planet.v1.GetBonusRulesResponse + 31, // 51: planet.v1.ClickService.OpenQuiz:output_type -> planet.v1.OpenQuizResponse + 33, // 52: planet.v1.ClickService.AnswerQuiz:output_type -> planet.v1.AnswerQuizResponse + 43, // 53: planet.v1.ClickService.PlaceShield:output_type -> planet.v1.PlaceShieldResponse + 16, // 54: planet.v1.ClickService.GetFortresses:output_type -> planet.v1.GetFortressesResponse 41, // [41:55] is the sub-list for method output_type 27, // [27:41] is the sub-list for method input_type 27, // [27:27] is the sub-list for extension type_name @@ -3026,7 +3075,7 @@ func file_planet_v1_planet_proto_init() { if File_planet_v1_planet_proto != nil { return } - file_planet_v1_planet_proto_msgTypes[11].OneofWrappers = []any{ + file_planet_v1_planet_proto_msgTypes[12].OneofWrappers = []any{ (*PlanetEvent_TileUpdate)(nil), (*PlanetEvent_Heartbeat)(nil), (*PlanetEvent_BonusOffered)(nil), @@ -3043,7 +3092,7 @@ func file_planet_v1_planet_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_planet_v1_planet_proto_rawDesc), len(file_planet_v1_planet_proto_rawDesc)), NumEnums: 2, - NumMessages: 43, + NumMessages: 44, NumExtensions: 0, NumServices: 1, }, diff --git a/apps/backend/internal/antibot/antibot.go b/apps/backend/internal/antibot/antibot.go index 21a368466..42fd8fb79 100644 --- a/apps/backend/internal/antibot/antibot.go +++ b/apps/backend/internal/antibot/antibot.go @@ -6,6 +6,7 @@ import ( "sync" "time" + "github.com/raphoester/clickplanet.lol-backend/internal/antibot/internal/bans" "github.com/raphoester/clickplanet.lol-backend/internal/antibot/internal/catcher" "github.com/raphoester/clickplanet.lol-backend/internal/antibot/internal/churner" "github.com/raphoester/clickplanet.lol-backend/internal/antibot/internal/cohort" @@ -22,7 +23,6 @@ import ( "github.com/raphoester/clickplanet.lol-backend/internal/antibot/internal/rotator" "github.com/raphoester/clickplanet.lol-backend/internal/antibot/internal/scraper" "github.com/raphoester/clickplanet.lol-backend/internal/antibot/internal/sequencer" - "github.com/raphoester/clickplanet.lol-backend/internal/antibot/internal/shadowban" "github.com/raphoester/clickplanet.lol-backend/internal/shared/cppg" "github.com/raphoester/clickplanet.lol-backend/internal/shared/cptime" ) @@ -31,7 +31,7 @@ type ( Click = detect.Click Point = detect.Point Report = detect.Report - Sentence = shadowban.Sentence + Sentence = bans.Sentence Examination = detect.Examination Reading = detect.Reading @@ -42,9 +42,9 @@ type Config struct { Database cppg.Config - ShadowBan shadowban.Config - Jury jury.Config - Evidence evidence.Config + Bans bans.Config + Jury jury.Config + Evidence evidence.Config Retaker retakerConfig Sequencer sequencerConfig @@ -200,8 +200,8 @@ func build( clock cptime.Clock, observer Observer, db database, - scopeBans shadowban.Store, - accountBans shadowban.Store, + scopeBans bans.Store, + accountBans bans.Store, evidences evidence.Persistence, ) (*Guard, error) { if clock == nil { @@ -349,7 +349,7 @@ func build( juryConfig := config.Jury.WithDefaults() - banner := shadowban.NewBans(config.ShadowBan, clock, scopeBans, accountBans) + banner := bans.NewBook(config.Bans, clock, scopeBans, accountBans) g.banner = banner g.jury = jury.New(juryConfig, banner, clock, juryHooks(observer), watchdogs...) @@ -393,7 +393,7 @@ type Description struct { type Guard struct { jury *jury.Jury - banner *shadowban.Bans + banner *bans.Book evidence *evidence.Store database database catcher *catcher.Watchdog @@ -414,20 +414,17 @@ func (g *Guard) Attempted(click Click) { const banTimeout = time.Second // Call before the handler runs: afterwards the map no longer knows who held the tile. -func (g *Guard) Inspect(ctx context.Context, click Click) (drop bool) { +func (g *Guard) Inspect(ctx context.Context, click Click) { if !g.Enabled() { - return false + return } ctx, cancel := context.WithTimeout(ctx, banTimeout) defer cancel() - drop, err := g.jury.Inspect(ctx, click) - if err != nil { + if err := g.jury.Inspect(ctx, click); err != nil { g.onStateError(err) } - - return drop } // Only for a click the handler accepted; a refused one would frame the next clicker. @@ -497,7 +494,7 @@ func (g *Guard) Ban(ctx context.Context, scope, account string, duration time.Du return Sentence{}, nil } - return g.banner.Ban(ctx, shadowban.Caller{Scope: scope, Account: account}, duration) //nolint:wrapcheck // the banner already named what failed. + return g.banner.Ban(ctx, bans.Caller{Scope: scope, Account: account}, duration) //nolint:wrapcheck // the banner already named what failed. } func (g *Guard) Unban(ctx context.Context, scope, account string) error { @@ -505,7 +502,7 @@ func (g *Guard) Unban(ctx context.Context, scope, account string) error { return nil } - return g.banner.Unban(ctx, shadowban.Caller{Scope: scope, Account: account}) //nolint:wrapcheck // the banner already named what failed. + return g.banner.Unban(ctx, bans.Caller{Scope: scope, Account: account}) //nolint:wrapcheck // the banner already named what failed. } func (g *Guard) Sentence(ctx context.Context, scope, account string) (Sentence, bool, error) { @@ -513,7 +510,7 @@ func (g *Guard) Sentence(ctx context.Context, scope, account string) (Sentence, return Sentence{}, false, nil } - return g.banner.Sentence(ctx, shadowban.Caller{Scope: scope, Account: account}) //nolint:wrapcheck // the banner already named what failed. + return g.banner.Sentence(ctx, bans.Caller{Scope: scope, Account: account}) //nolint:wrapcheck // the banner already named what failed. } func (g *Guard) LastSentence(ctx context.Context, scope, account string) (Sentence, bool, error) { @@ -521,7 +518,7 @@ func (g *Guard) LastSentence(ctx context.Context, scope, account string) (Senten return Sentence{}, false, nil } - return g.banner.LastSentence(ctx, shadowban.Caller{Scope: scope, Account: account}) //nolint:wrapcheck // the banner already named what failed. + return g.banner.LastSentence(ctx, bans.Caller{Scope: scope, Account: account}) //nolint:wrapcheck // the banner already named what failed. } func (g *Guard) Examine(ctx context.Context, scope, account string) (Examination, error) { @@ -532,7 +529,7 @@ func (g *Guard) Examine(ctx context.Context, scope, account string) (Examination examination := g.jury.Examine(scope) examination.Account = account - sentence, banned, err := g.banner.Sentence(ctx, shadowban.Caller{Scope: scope, Account: account}) + sentence, banned, err := g.banner.Sentence(ctx, bans.Caller{Scope: scope, Account: account}) if err != nil { return Examination{}, err //nolint:wrapcheck // the banner already named what failed. } @@ -548,20 +545,22 @@ func (g *Guard) Examine(ctx context.Context, scope, account string) (Examination func (g *Guard) Enforcing() bool { return g.Enabled() && g.banner.Enforcing() } -func (g *Guard) Banned(ctx context.Context, scope, account string) bool { +// A ban that cannot be read is no ban: the caller plays on, and the error says why. +func (g *Guard) EnforcedSentence(ctx context.Context, scope, account string) (Sentence, bool) { if !g.Enabled() { - return false + return Sentence{}, false } ctx, cancel := context.WithTimeout(ctx, banTimeout) defer cancel() - banned, err := g.banner.Banned(ctx, shadowban.Caller{Scope: scope, Account: account}) + sentence, banned, err := g.banner.EnforcedSentence(ctx, bans.Caller{Scope: scope, Account: account}) if err != nil { g.onStateError(err) + return Sentence{}, false } - return banned + return sentence, banned } func (g *Guard) Enabled() bool { return g.jury != nil } diff --git a/apps/backend/internal/antibot/antibot_test.go b/apps/backend/internal/antibot/antibot_test.go index c844a560c..fbe32adc3 100644 --- a/apps/backend/internal/antibot/antibot_test.go +++ b/apps/backend/internal/antibot/antibot_test.go @@ -12,9 +12,9 @@ import ( "github.com/stretchr/testify/require" "github.com/raphoester/clickplanet.lol-backend/internal/antibot" + "github.com/raphoester/clickplanet.lol-backend/internal/antibot/internal/bans" "github.com/raphoester/clickplanet.lol-backend/internal/antibot/internal/detect" "github.com/raphoester/clickplanet.lol-backend/internal/antibot/internal/evidence" - "github.com/raphoester/clickplanet.lol-backend/internal/antibot/internal/shadowban" "github.com/raphoester/clickplanet.lol-backend/internal/shared/cpcolls" "github.com/raphoester/clickplanet.lol-backend/internal/shared/cppg" "github.com/raphoester/clickplanet.lol-backend/internal/shared/cptime" @@ -27,8 +27,8 @@ type stack struct { config antibot.Config stop func() - bans *shadowban.MemoryStore - accountBans *shadowban.MemoryStore + bans *bans.MemoryStore + accountBans *bans.MemoryStore evidence *evidence.MemoryPersistence forgetEvidence bool @@ -48,16 +48,16 @@ func newStack(t *testing.T, options ...func(*antibot.Config)) *stack { owner: map[uint32]string{}, shields: map[uint32]int{}, linked: cpcolls.NewSet[string](), - bans: shadowban.NewMemoryStore(), - accountBans: shadowban.NewMemoryStore(), + bans: bans.NewMemoryStore(), + accountBans: bans.NewMemoryStore(), evidence: evidence.NewMemoryPersistence(), } config := antibot.Config{Enabled: true} - config.ShadowBan.Enforce = true - config.ShadowBan.BanDurations = []time.Duration{time.Hour} - config.ShadowBan.ReflagInterval = 5 * time.Minute + config.Bans.Enforce = true + config.Bans.BanDurations = []time.Duration{time.Hour} + config.Bans.ReflagInterval = 5 * time.Minute config.Jury.MinSuspects = 2 config.Jury.SuspicionWindow = 10 * time.Minute @@ -195,7 +195,8 @@ func (s *stack) restart(outage time.Duration) { } func (s *stack) inspect(click antibot.Click) bool { - return s.guard.Inspect(s.t.Context(), click) + s.guard.Inspect(s.t.Context(), click) + return s.banned(click.Scope, click.Account) } func (s *stack) ban(scope, account string, duration time.Duration) antibot.Sentence { @@ -211,7 +212,8 @@ func (s *stack) unban(scope, account string) { } func (s *stack) banned(scope, account string) bool { - return s.guard.Banned(s.t.Context(), scope, account) + _, banned := s.guard.EnforcedSentence(s.t.Context(), scope, account) + return banned } func (s *stack) examine(scope, account string) antibot.Examination { @@ -826,8 +828,9 @@ func TestWithTheBlockOffTheGuardPassesEveryClick(t *testing.T) { require.NoError(t, err) assert.False(t, guard.Enabled()) - assert.False(t, guard.Inspect(t.Context(), antibot.Click{Scope: "1.2.3.4", Tile: 1, Country: "fr"})) - assert.False(t, guard.Banned(t.Context(), "1.2.3.4", "a-guest")) + guard.Inspect(t.Context(), antibot.Click{Scope: "1.2.3.4", Tile: 1, Country: "fr"}) + _, banned := guard.EnforcedSentence(t.Context(), "1.2.3.4", "a-guest") + assert.False(t, banned) } func TestALoopRestartedEveryFewMinutesIsStillCaught(t *testing.T) { diff --git a/apps/backend/internal/antibot/antibot_testing.go b/apps/backend/internal/antibot/antibot_testing.go index b1fb63b38..310cc827b 100644 --- a/apps/backend/internal/antibot/antibot_testing.go +++ b/apps/backend/internal/antibot/antibot_testing.go @@ -5,8 +5,8 @@ package antibot import ( "context" + "github.com/raphoester/clickplanet.lol-backend/internal/antibot/internal/bans" "github.com/raphoester/clickplanet.lol-backend/internal/antibot/internal/evidence" - "github.com/raphoester/clickplanet.lol-backend/internal/antibot/internal/shadowban" "github.com/raphoester/clickplanet.lol-backend/internal/shared/cptime" ) @@ -14,8 +14,8 @@ func NewInMemory( config Config, clock cptime.Clock, observer Observer, - bans *shadowban.MemoryStore, - accountBans *shadowban.MemoryStore, + bans *bans.MemoryStore, + accountBans *bans.MemoryStore, evidences *evidence.MemoryPersistence, ) (*Guard, error) { if !config.Enabled { diff --git a/apps/backend/internal/antibot/internal/shadowban/shadowban.go b/apps/backend/internal/antibot/internal/bans/banner.go similarity index 96% rename from apps/backend/internal/antibot/internal/shadowban/shadowban.go rename to apps/backend/internal/antibot/internal/bans/banner.go index ba3c77847..e25e1fe02 100644 --- a/apps/backend/internal/antibot/internal/shadowban/shadowban.go +++ b/apps/backend/internal/antibot/internal/bans/banner.go @@ -1,4 +1,4 @@ -package shadowban +package bans import ( "context" @@ -194,13 +194,12 @@ func (b *Banner) duration(offence int) time.Duration { return ladder[offence-1] } -func (b *Banner) Banned(ctx context.Context, scope string) (bool, error) { +func (b *Banner) EnforcedSentence(ctx context.Context, scope string) (Sentence, bool, error) { if !b.config.Enforce { - return false, nil + return Sentence{}, false, nil } - _, running, err := b.Sentence(ctx, scope) - return running, err + return b.Sentence(ctx, scope) } func (b *Banner) Flagged(ctx context.Context) (int, error) { diff --git a/apps/backend/internal/antibot/internal/shadowban/shadowban_test.go b/apps/backend/internal/antibot/internal/bans/banner_test.go similarity index 92% rename from apps/backend/internal/antibot/internal/shadowban/shadowban_test.go rename to apps/backend/internal/antibot/internal/bans/banner_test.go index 995f14405..b7c727561 100644 --- a/apps/backend/internal/antibot/internal/shadowban/shadowban_test.go +++ b/apps/backend/internal/antibot/internal/bans/banner_test.go @@ -1,4 +1,4 @@ -package shadowban_test +package bans_test import ( "testing" @@ -7,7 +7,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/raphoester/clickplanet.lol-backend/internal/antibot/internal/shadowban" + "github.com/raphoester/clickplanet.lol-backend/internal/antibot/internal/bans" "github.com/raphoester/clickplanet.lol-backend/internal/shared/cptime" ) @@ -17,18 +17,18 @@ func newClock() *cptime.FixedClock { const threeYears = 3 * 365 * 24 * time.Hour -func newBanner(t *testing.T, config shadowban.Config, clock cptime.Clock) banner { +func newBanner(t *testing.T, config bans.Config, clock cptime.Clock) banner { t.Helper() - return bannerOver(t, config, clock, shadowban.NewMemoryStore()) + return bannerOver(t, config, clock, bans.NewMemoryStore()) } -func bannerOver(t *testing.T, config shadowban.Config, clock cptime.Clock, store shadowban.Store) banner { +func bannerOver(t *testing.T, config bans.Config, clock cptime.Clock, store bans.Store) banner { t.Helper() - return banner{t: t, inner: shadowban.New(config, clock, store)} + return banner{t: t, inner: bans.New(config, clock, store)} } -func config() shadowban.Config { - return shadowban.Config{ +func config() bans.Config { + return bans.Config{ Enforce: true, BanDurations: []time.Duration{time.Hour, 24 * time.Hour, threeYears}, ReflagInterval: 5 * time.Minute, @@ -150,7 +150,7 @@ func TestTheLastStepRepeats(t *testing.T) { clock := newClock() banner := newBanner(t, c, clock) - var sentence shadowban.Sentence + var sentence bans.Sentence for range 5 { sentence, _ = banner.Flag("bot") clock.Advance(25 * time.Hour) @@ -185,7 +185,7 @@ func TestAManualBanTakesTheLadderAndCountsAsAnOffence(t *testing.T) { banner := newBanner(t, config(), clock) sentence := banner.Ban("bot", 0) - assert.Equal(t, shadowban.Sentence{Offence: 1, Until: clock.Now().Add(time.Hour)}, sentence) + assert.Equal(t, bans.Sentence{Offence: 1, Until: clock.Now().Add(time.Hour)}, sentence) assert.True(t, banner.Banned("bot")) clock.Advance(2 * time.Hour) @@ -215,7 +215,7 @@ func TestAManualBanWithADurationNeverShortensARunningOne(t *testing.T) { func TestAManualBanOnAnEmptyScopeBansNothing(t *testing.T) { banner := newBanner(t, config(), newClock()) - assert.Equal(t, shadowban.Sentence{}, banner.Ban("", time.Hour)) + assert.Equal(t, bans.Sentence{}, banner.Ban("", time.Hour)) assert.Equal(t, 0, banner.Flagged()) } @@ -254,7 +254,7 @@ func TestAnUnbanForgetsOnlyTheOffenceItLifts(t *testing.T) { func TestAnUnbanOfAnEndedBanForgetsItsOffenceAndKeepsItsEnd(t *testing.T) { clock := newClock() - store := shadowban.NewMemoryStore() + store := bans.NewMemoryStore() banner := bannerOver(t, config(), clock, store) banned, _ := banner.Flag("player") @@ -293,7 +293,7 @@ func TestEachUnbanForgetsOneOffence(t *testing.T) { func TestAnUnbanWithNoBanChangesNothing(t *testing.T) { clock := newClock() - store := shadowban.NewMemoryStore() + store := bans.NewMemoryStore() banner := bannerOver(t, config(), clock, store) banner.Flag("lifted") diff --git a/apps/backend/internal/antibot/internal/shadowban/bans.go b/apps/backend/internal/antibot/internal/bans/book.go similarity index 71% rename from apps/backend/internal/antibot/internal/shadowban/bans.go rename to apps/backend/internal/antibot/internal/bans/book.go index b21d787a4..a50f339d7 100644 --- a/apps/backend/internal/antibot/internal/shadowban/bans.go +++ b/apps/backend/internal/antibot/internal/bans/book.go @@ -1,4 +1,4 @@ -package shadowban +package bans import ( "context" @@ -14,19 +14,19 @@ type Caller struct { SignedIn bool } -func NewBans(config Config, clock cptime.Clock, scopes, accounts Store) *Bans { - return &Bans{ +func NewBook(config Config, clock cptime.Clock, scopes, accounts Store) *Book { + return &Book{ scopes: New(config, clock, scopes), accounts: New(config, clock, accounts), } } -type Bans struct { +type Book struct { scopes *Banner accounts *Banner } -func (b *Bans) Flag(ctx context.Context, caller Caller) (Sentence, bool, error) { +func (b *Book) Flag(ctx context.Context, caller Caller) (Sentence, bool, error) { var ( sentence Sentence accepted bool @@ -45,7 +45,7 @@ func (b *Bans) Flag(ctx context.Context, caller Caller) (Sentence, bool, error) return sentence, accepted, nil } -func (b *Bans) Ban(ctx context.Context, caller Caller, duration time.Duration) (Sentence, error) { +func (b *Book) Ban(ctx context.Context, caller Caller, duration time.Duration) (Sentence, error) { var sentence Sentence for _, target := range b.targets(caller) { s, err := target.banner.Ban(ctx, target.key, duration) @@ -58,29 +58,26 @@ func (b *Bans) Ban(ctx context.Context, caller Caller, duration time.Duration) ( return sentence, nil } -func (b *Bans) Unban(ctx context.Context, caller Caller) error { +func (b *Book) Unban(ctx context.Context, caller Caller) error { if err := b.scopes.Unban(ctx, caller.Scope); err != nil { return err } return b.accounts.Unban(ctx, caller.Account) } -func (b *Bans) Banned(ctx context.Context, caller Caller) (bool, error) { - if banned, err := b.scopes.Banned(ctx, caller.Scope); err != nil || banned { - return banned, err - } - return b.accounts.Banned(ctx, caller.Account) +func (b *Book) EnforcedSentence(ctx context.Context, caller Caller) (Sentence, bool, error) { + return b.sentenceOf(ctx, caller, (*Banner).EnforcedSentence) } -func (b *Bans) Sentence(ctx context.Context, caller Caller) (Sentence, bool, error) { +func (b *Book) Sentence(ctx context.Context, caller Caller) (Sentence, bool, error) { return b.sentenceOf(ctx, caller, (*Banner).Sentence) } -func (b *Bans) LastSentence(ctx context.Context, caller Caller) (Sentence, bool, error) { +func (b *Book) LastSentence(ctx context.Context, caller Caller) (Sentence, bool, error) { return b.sentenceOf(ctx, caller, (*Banner).LastSentence) } -func (b *Bans) sentenceOf( +func (b *Book) sentenceOf( ctx context.Context, caller Caller, read func(*Banner, context.Context, string) (Sentence, bool, error), @@ -97,7 +94,7 @@ func (b *Bans) sentenceOf( return latest(scope, account), onScope || onAccount, nil } -func (b *Bans) Flagged(ctx context.Context) (int, error) { +func (b *Book) Flagged(ctx context.Context) (int, error) { scopes, err := b.scopes.Flagged(ctx) if err != nil { return 0, err @@ -109,14 +106,14 @@ func (b *Bans) Flagged(ctx context.Context) (int, error) { return scopes + accounts, nil } -func (b *Bans) Enforcing() bool { return b.scopes.Enforcing() } +func (b *Book) Enforcing() bool { return b.scopes.Enforcing() } type target struct { banner *Banner key string } -func (b *Bans) targets(caller Caller) []target { +func (b *Book) targets(caller Caller) []target { targets := make([]target, 0, 2) if caller.Account != "" { targets = append(targets, target{banner: b.accounts, key: caller.Account}) diff --git a/apps/backend/internal/antibot/internal/bans/book_test.go b/apps/backend/internal/antibot/internal/bans/book_test.go new file mode 100644 index 000000000..03ff7952b --- /dev/null +++ b/apps/backend/internal/antibot/internal/bans/book_test.go @@ -0,0 +1,131 @@ +package bans_test + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/raphoester/clickplanet.lol-backend/internal/antibot/internal/bans" + "github.com/raphoester/clickplanet.lol-backend/internal/shared/cptime" +) + +func newBook(t *testing.T, clock cptime.Clock) (book, *bans.MemoryStore, *bans.MemoryStore) { + t.Helper() + + scopes, accounts := bans.NewMemoryStore(), bans.NewMemoryStore() + return book{t: t, inner: bans.NewBook(config(), clock, scopes, accounts)}, scopes, accounts +} + +func TestAFlaggedGuestIsBannedOnItsAccountAndItsScope(t *testing.T) { + book, _, _ := newBook(t, newClock()) + + _, accepted := book.Flag(bans.Caller{Scope: "1.2.3.4", Account: "guest"}) + require.True(t, accepted) + + assert.True(t, book.Banned(bans.Caller{Scope: "1.2.3.4", Account: "a-fresh-cookie"}), + "a new cookie on the same scope is still dropped") + assert.True(t, book.Banned(bans.Caller{Scope: "5.6.7.8", Account: "guest"}), + "the same account on another scope is still dropped") + assert.False(t, book.Banned(bans.Caller{Scope: "5.6.7.8", Account: "someone-else"})) + assert.Equal(t, 2, book.Flagged(), "one guest, banned twice") +} + +func TestAFlaggedSignedInAccountLeavesItsScopeAlone(t *testing.T) { + book, _, _ := newBook(t, newClock()) + + _, accepted := book.Flag(bans.Caller{Scope: "campus", Account: "player", SignedIn: true}) + require.True(t, accepted) + + assert.True(t, book.Banned(bans.Caller{Scope: "home", Account: "player", SignedIn: true})) + assert.False(t, book.Banned(bans.Caller{Scope: "campus", Account: "classmate", SignedIn: true}), + "the other players behind the campus address are not banned with it") +} + +func TestAFlagWithNoAccountBansTheScope(t *testing.T) { + book, _, _ := newBook(t, newClock()) + + _, accepted := book.Flag(bans.Caller{Scope: "1.2.3.4"}) + require.True(t, accepted) + + assert.True(t, book.Banned(bans.Caller{Scope: "1.2.3.4"})) + assert.True(t, book.Banned(bans.Caller{Scope: "1.2.3.4", Account: "guest"})) +} + +func TestAnOperatorBanOnAnAccountBansTheAccountAlone(t *testing.T) { + clock := newClock() + book, _, _ := newBook(t, clock) + + sentence := book.Ban(bans.Caller{Account: "guest"}, 0) + assert.Equal(t, 1, sentence.Offence) + assert.Equal(t, clock.Now().Add(time.Hour), sentence.Until) + + assert.True(t, book.Banned(bans.Caller{Scope: "1.2.3.4", Account: "guest"})) + assert.False(t, book.Banned(bans.Caller{Scope: "1.2.3.4"})) +} + +func TestTheSentenceIsTheBanThatEndsLast(t *testing.T) { + clock := newClock() + book, _, _ := newBook(t, clock) + + book.Ban(bans.Caller{Scope: "1.2.3.4"}, time.Hour) + book.Ban(bans.Caller{Account: "guest"}, 2*time.Hour) + + sentence, running := book.Sentence(bans.Caller{Scope: "1.2.3.4", Account: "guest"}) + require.True(t, running) + assert.Equal(t, clock.Now().Add(2*time.Hour), sentence.Until) + + _, running = book.Sentence(bans.Caller{Scope: "5.6.7.8", Account: "someone-else"}) + assert.False(t, running) +} + +func TestAccountBansAreKeptApart(t *testing.T) { + clock := newClock() + before, scopes, accounts := newBook(t, clock) + + before.Flag(bans.Caller{Scope: "1.2.3.4", Account: "guest"}) + + assert.Contains(t, scopes.Stored(), "1.2.3.4") + assert.NotContains(t, scopes.Stored(), "guest") + assert.Contains(t, accounts.Stored(), "guest") + + after := book{t: t, inner: bans.NewBook(config(), clock, scopes, accounts)} + assert.True(t, after.Banned(bans.Caller{Scope: "5.6.7.8", Account: "guest"})) +} + +func TestAnUnbanLiftsOnlyTheBanItNames(t *testing.T) { + book, _, _ := newBook(t, newClock()) + guest := bans.Caller{Scope: "1.2.3.4", Account: "guest"} + + book.Flag(guest) + + book.Unban(bans.Caller{Account: "guest"}) + assert.True(t, book.Banned(guest), "the guest's scope is still banned") + assert.False(t, book.Banned(bans.Caller{Scope: "5.6.7.8", Account: "guest"})) + + book.Unban(bans.Caller{Scope: "1.2.3.4"}) + assert.False(t, book.Banned(guest)) + assert.Equal(t, 0, book.Flagged()) +} + +func TestTheLastSentenceOfAnAccountIsReadAfterItsBanEnded(t *testing.T) { + clock := newClock() + book, _, accounts := newBook(t, clock) + player := bans.Caller{Account: "player"} + + banned := book.Ban(player, 0) + clock.Advance(2 * time.Hour) + + _, running := book.Sentence(player) + require.False(t, running) + + last, found := book.LastSentence(player) + require.True(t, found) + assert.Equal(t, banned, last) + + book.Unban(player) + _, found = book.LastSentence(player) + assert.False(t, found) + assert.Equal(t, 0, accounts.Stored()["player"].Offences) +} diff --git a/apps/backend/internal/antibot/internal/shadowban/helpers_test.go b/apps/backend/internal/antibot/internal/bans/helpers_test.go similarity index 66% rename from apps/backend/internal/antibot/internal/shadowban/helpers_test.go rename to apps/backend/internal/antibot/internal/bans/helpers_test.go index 13a4d3574..bb10355c5 100644 --- a/apps/backend/internal/antibot/internal/shadowban/helpers_test.go +++ b/apps/backend/internal/antibot/internal/bans/helpers_test.go @@ -1,4 +1,4 @@ -package shadowban_test +package bans_test import ( "testing" @@ -6,22 +6,22 @@ import ( "github.com/stretchr/testify/require" - "github.com/raphoester/clickplanet.lol-backend/internal/antibot/internal/shadowban" + "github.com/raphoester/clickplanet.lol-backend/internal/antibot/internal/bans" ) type banner struct { t *testing.T - inner *shadowban.Banner + inner *bans.Banner } -func (b banner) Flag(scope string) (shadowban.Sentence, bool) { +func (b banner) Flag(scope string) (bans.Sentence, bool) { b.t.Helper() sentence, accepted, err := b.inner.Flag(b.t.Context(), scope) require.NoError(b.t, err) return sentence, accepted } -func (b banner) Ban(scope string, duration time.Duration) shadowban.Sentence { +func (b banner) Ban(scope string, duration time.Duration) bans.Sentence { b.t.Helper() sentence, err := b.inner.Ban(b.t.Context(), scope, duration) require.NoError(b.t, err) @@ -33,14 +33,14 @@ func (b banner) Unban(scope string) { require.NoError(b.t, b.inner.Unban(b.t.Context(), scope)) } -func (b banner) Sentence(scope string) (shadowban.Sentence, bool) { +func (b banner) Sentence(scope string) (bans.Sentence, bool) { b.t.Helper() sentence, running, err := b.inner.Sentence(b.t.Context(), scope) require.NoError(b.t, err) return sentence, running } -func (b banner) LastSentence(scope string) (shadowban.Sentence, bool) { +func (b banner) LastSentence(scope string) (bans.Sentence, bool) { b.t.Helper() sentence, found, err := b.inner.LastSentence(b.t.Context(), scope) require.NoError(b.t, err) @@ -49,7 +49,7 @@ func (b banner) LastSentence(scope string) (shadowban.Sentence, bool) { func (b banner) Banned(scope string) bool { b.t.Helper() - banned, err := b.inner.Banned(b.t.Context(), scope) + _, banned, err := b.inner.EnforcedSentence(b.t.Context(), scope) require.NoError(b.t, err) return banned } @@ -63,52 +63,52 @@ func (b banner) Flagged() int { func (b banner) Enforcing() bool { return b.inner.Enforcing() } -type bans struct { +type book struct { t *testing.T - inner *shadowban.Bans + inner *bans.Book } -func (b bans) Flag(caller shadowban.Caller) (shadowban.Sentence, bool) { +func (b book) Flag(caller bans.Caller) (bans.Sentence, bool) { b.t.Helper() sentence, accepted, err := b.inner.Flag(b.t.Context(), caller) require.NoError(b.t, err) return sentence, accepted } -func (b bans) Ban(caller shadowban.Caller, duration time.Duration) shadowban.Sentence { +func (b book) Ban(caller bans.Caller, duration time.Duration) bans.Sentence { b.t.Helper() sentence, err := b.inner.Ban(b.t.Context(), caller, duration) require.NoError(b.t, err) return sentence } -func (b bans) Unban(caller shadowban.Caller) { +func (b book) Unban(caller bans.Caller) { b.t.Helper() require.NoError(b.t, b.inner.Unban(b.t.Context(), caller)) } -func (b bans) Sentence(caller shadowban.Caller) (shadowban.Sentence, bool) { +func (b book) Sentence(caller bans.Caller) (bans.Sentence, bool) { b.t.Helper() sentence, running, err := b.inner.Sentence(b.t.Context(), caller) require.NoError(b.t, err) return sentence, running } -func (b bans) LastSentence(caller shadowban.Caller) (shadowban.Sentence, bool) { +func (b book) LastSentence(caller bans.Caller) (bans.Sentence, bool) { b.t.Helper() sentence, found, err := b.inner.LastSentence(b.t.Context(), caller) require.NoError(b.t, err) return sentence, found } -func (b bans) Banned(caller shadowban.Caller) bool { +func (b book) Banned(caller bans.Caller) bool { b.t.Helper() - banned, err := b.inner.Banned(b.t.Context(), caller) + _, banned, err := b.inner.EnforcedSentence(b.t.Context(), caller) require.NoError(b.t, err) return banned } -func (b bans) Flagged() int { +func (b book) Flagged() int { b.t.Helper() flagged, err := b.inner.Flagged(b.t.Context()) require.NoError(b.t, err) diff --git a/apps/backend/internal/antibot/internal/shadowban/store.go b/apps/backend/internal/antibot/internal/bans/store.go similarity index 96% rename from apps/backend/internal/antibot/internal/shadowban/store.go rename to apps/backend/internal/antibot/internal/bans/store.go index 0ae6d65b8..9b0608568 100644 --- a/apps/backend/internal/antibot/internal/shadowban/store.go +++ b/apps/backend/internal/antibot/internal/bans/store.go @@ -1,4 +1,4 @@ -package shadowban +package bans import ( "context" diff --git a/apps/backend/internal/antibot/internal/shadowban/store_contract_test.go b/apps/backend/internal/antibot/internal/bans/store_contract_test.go similarity index 62% rename from apps/backend/internal/antibot/internal/shadowban/store_contract_test.go rename to apps/backend/internal/antibot/internal/bans/store_contract_test.go index 527d6168c..498798276 100644 --- a/apps/backend/internal/antibot/internal/shadowban/store_contract_test.go +++ b/apps/backend/internal/antibot/internal/bans/store_contract_test.go @@ -1,4 +1,4 @@ -package shadowban_test +package bans_test import ( "fmt" @@ -6,12 +6,12 @@ import ( "github.com/stretchr/testify/suite" - "github.com/raphoester/clickplanet.lol-backend/internal/antibot/internal/shadowban" + "github.com/raphoester/clickplanet.lol-backend/internal/antibot/internal/bans" ) func TestTheMemoryStoreKeepsTheContract(t *testing.T) { - suite.Run(t, &shadowban.StoreContractSuite{ - NewStore: func() shadowban.Store { return shadowban.NewMemoryStore() }, + suite.Run(t, &bans.StoreContractSuite{ + NewStore: func() bans.Store { return bans.NewMemoryStore() }, Key: func(n int) string { return fmt.Sprintf("1.2.3.%d", n) }, }) } diff --git a/apps/backend/internal/antibot/internal/shadowban/store_contract_testing.go b/apps/backend/internal/antibot/internal/bans/store_contract_testing.go similarity index 99% rename from apps/backend/internal/antibot/internal/shadowban/store_contract_testing.go rename to apps/backend/internal/antibot/internal/bans/store_contract_testing.go index a1ae167d4..cdc302491 100644 --- a/apps/backend/internal/antibot/internal/shadowban/store_contract_testing.go +++ b/apps/backend/internal/antibot/internal/bans/store_contract_testing.go @@ -1,6 +1,6 @@ //go:build testing -package shadowban +package bans import ( "sync" diff --git a/apps/backend/internal/antibot/internal/shadowban/store_test.go b/apps/backend/internal/antibot/internal/bans/store_test.go similarity index 83% rename from apps/backend/internal/antibot/internal/shadowban/store_test.go rename to apps/backend/internal/antibot/internal/bans/store_test.go index be7b0e207..95e7baf8e 100644 --- a/apps/backend/internal/antibot/internal/shadowban/store_test.go +++ b/apps/backend/internal/antibot/internal/bans/store_test.go @@ -1,4 +1,4 @@ -package shadowban_test +package bans_test import ( "errors" @@ -8,12 +8,12 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/raphoester/clickplanet.lol-backend/internal/antibot/internal/shadowban" + "github.com/raphoester/clickplanet.lol-backend/internal/antibot/internal/bans" ) func TestTheBansAreTheStoresAndNotTheBanners(t *testing.T) { clock := newClock() - store := shadowban.NewMemoryStore() + store := bans.NewMemoryStore() before := bannerOver(t, config(), clock, store) before.Flag("repeat") @@ -39,7 +39,7 @@ func TestTheBansAreTheStoresAndNotTheBanners(t *testing.T) { func TestTheReflagIntervalIsKeptInTheStore(t *testing.T) { clock := newClock() - store := shadowban.NewMemoryStore() + store := bans.NewMemoryStore() bannerOver(t, config(), clock, store).Flag("bot") @@ -54,7 +54,7 @@ func TestTheReflagIntervalIsKeptInTheStore(t *testing.T) { func TestABanInTheStoreIsRunning(t *testing.T) { clock := newClock() - store := shadowban.NewMemoryStore(shadowban.Record{Key: "kept", Flags: 1, Offences: 1, ExpiresAt: clock.Now().Add(time.Hour)}) + store := bans.NewMemoryStore(bans.Record{Key: "kept", Flags: 1, Offences: 1, ExpiresAt: clock.Now().Add(time.Hour)}) banner := bannerOver(t, config(), clock, store) @@ -64,10 +64,10 @@ func TestABanInTheStoreIsRunning(t *testing.T) { } func TestAFailingStoreIsAnError(t *testing.T) { - store := shadowban.NewMemoryStore() + store := bans.NewMemoryStore() cause := errors.New("postgres is down") store.FailWith(cause) - banner := shadowban.New(config(), newClock(), store) + banner := bans.New(config(), newClock(), store) ctx := t.Context() _, _, err := banner.Flag(ctx, "bot") @@ -77,19 +77,19 @@ func TestAFailingStoreIsAnError(t *testing.T) { require.ErrorIs(t, banner.Unban(ctx, "bot"), cause) _, _, err = banner.Sentence(ctx, "bot") require.ErrorIs(t, err, cause) - _, err = banner.Banned(ctx, "bot") + _, _, err = banner.EnforcedSentence(ctx, "bot") require.ErrorIs(t, err, cause) _, err = banner.Flagged(ctx) require.ErrorIs(t, err, cause) } func TestWithEnforceOffBannedReadsNothing(t *testing.T) { - store := shadowban.NewMemoryStore() + store := bans.NewMemoryStore() store.FailWith(errors.New("postgres is down")) c := config() c.Enforce = false - banned, err := shadowban.New(c, newClock(), store).Banned(t.Context(), "bot") + _, banned, err := bans.New(c, newClock(), store).EnforcedSentence(t.Context(), "bot") require.NoError(t, err) assert.False(t, banned) @@ -97,7 +97,7 @@ func TestWithEnforceOffBannedReadsNothing(t *testing.T) { func TestAnUnbanIsKeptInTheStore(t *testing.T) { clock := newClock() - store := shadowban.NewMemoryStore() + store := bans.NewMemoryStore() flaggedAt := clock.Now() before := bannerOver(t, config(), clock, store) @@ -105,7 +105,7 @@ func TestAnUnbanIsKeptInTheStore(t *testing.T) { clock.Advance(10 * time.Minute) before.Unban("player") - assert.Equal(t, shadowban.Record{ + assert.Equal(t, bans.Record{ Key: "player", Flags: 1, ExpiresAt: clock.Now(), LastFlaggedAt: flaggedAt, }, store.Stored()["player"]) diff --git a/apps/backend/internal/antibot/internal/shadowban/store_testing.go b/apps/backend/internal/antibot/internal/bans/store_testing.go similarity index 98% rename from apps/backend/internal/antibot/internal/shadowban/store_testing.go rename to apps/backend/internal/antibot/internal/bans/store_testing.go index c154e309b..e7fd7f3b6 100644 --- a/apps/backend/internal/antibot/internal/shadowban/store_testing.go +++ b/apps/backend/internal/antibot/internal/bans/store_testing.go @@ -1,6 +1,6 @@ //go:build testing -package shadowban +package bans import ( "context" diff --git a/apps/backend/internal/antibot/internal/jury/jury.go b/apps/backend/internal/antibot/internal/jury/jury.go index 4a642c186..0d35c978f 100644 --- a/apps/backend/internal/antibot/internal/jury/jury.go +++ b/apps/backend/internal/antibot/internal/jury/jury.go @@ -6,8 +6,8 @@ import ( "sync" "time" + "github.com/raphoester/clickplanet.lol-backend/internal/antibot/internal/bans" "github.com/raphoester/clickplanet.lol-backend/internal/antibot/internal/detect" - "github.com/raphoester/clickplanet.lol-backend/internal/antibot/internal/shadowban" "github.com/raphoester/clickplanet.lol-backend/internal/shared/cpcolls" "github.com/raphoester/clickplanet.lol-backend/internal/shared/cptime" ) @@ -50,8 +50,7 @@ func (c Config) WithDefaults() Config { } type Banner interface { - Flag(ctx context.Context, caller shadowban.Caller) (shadowban.Sentence, bool, error) - Banned(ctx context.Context, caller shadowban.Caller) (bool, error) + Flag(ctx context.Context, caller bans.Caller) (bans.Sentence, bool, error) Flagged(ctx context.Context) (int, error) } @@ -110,15 +109,13 @@ type caller struct { reached map[string]*[detect.Certain + 1]time.Time } -// A ban that cannot be read is no ban: the click goes through, and the error says why. -func (j *Jury) Inspect(ctx context.Context, click detect.Click) (bool, error) { +func (j *Jury) Inspect(ctx context.Context, click detect.Click) error { if click.Scope == "" { - return false, nil + return nil } j.record(click) - // No short-circuit on a ban: a starved watchdog lets the ban lapse on fake silence. for _, watchdog := range j.watchdogs { verdict, evidence := watchdog.Watch(click) for _, level := range j.opine(click, watchdog.Name(), verdict, evidence) { @@ -128,13 +125,11 @@ func (j *Jury) Inspect(ctx context.Context, click detect.Click) (bool, error) { } } - var flagErr error if report, guilty := j.deliberate(click); guilty { - flagErr = j.flag(ctx, click, report) + return j.flag(ctx, click, report) } - banned, err := j.banner.Banned(ctx, callerOf(click)) - return banned, errors.Join(flagErr, err) + return nil } func (j *Jury) flag(ctx context.Context, click detect.Click, report detect.Report) error { @@ -161,12 +156,12 @@ func (j *Jury) flag(ctx context.Context, click detect.Click, report detect.Repor return errors.Join(errs...) } -func callerOf(click detect.Click) shadowban.Caller { - return shadowban.Caller{Scope: click.Scope, Account: click.Account, SignedIn: click.SignedIn} +func callerOf(click detect.Click) bans.Caller { + return bans.Caller{Scope: click.Scope, Account: click.Account, SignedIn: click.SignedIn} } -func convictsOf(click detect.Click, report detect.Report) []shadowban.Caller { - convicts := []shadowban.Caller{callerOf(click)} +func convictsOf(click detect.Click, report detect.Report) []bans.Caller { + convicts := []bans.Caller{callerOf(click)} named := cpcolls.NewSet(click.Account) for _, opinion := range report.Opinions { @@ -178,7 +173,7 @@ func convictsOf(click detect.Click, report detect.Report) []shadowban.Caller { continue } named.Add(accomplice.Account) - convicts = append(convicts, shadowban.Caller{Scope: click.Scope, Account: accomplice.Account, SignedIn: accomplice.SignedIn}) + convicts = append(convicts, bans.Caller{Scope: click.Scope, Account: accomplice.Account, SignedIn: accomplice.SignedIn}) } } diff --git a/apps/backend/internal/antibot/internal/jury/jury_test.go b/apps/backend/internal/antibot/internal/jury/jury_test.go index ce9edd56a..217a7d8be 100644 --- a/apps/backend/internal/antibot/internal/jury/jury_test.go +++ b/apps/backend/internal/antibot/internal/jury/jury_test.go @@ -8,9 +8,9 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/raphoester/clickplanet.lol-backend/internal/antibot/internal/bans" "github.com/raphoester/clickplanet.lol-backend/internal/antibot/internal/detect" "github.com/raphoester/clickplanet.lol-backend/internal/antibot/internal/jury" - "github.com/raphoester/clickplanet.lol-backend/internal/antibot/internal/shadowban" "github.com/raphoester/clickplanet.lol-backend/internal/shared/cptime" ) @@ -45,17 +45,18 @@ func (w *stubWatchdog) Attempted(detect.Click) { w.attempted++ } type harness struct { t *testing.T jury *jury.Jury + banner *bans.Book clock *cptime.FixedClock reports []detect.Report rises []string } -func newHarness(t *testing.T, config jury.Config, ban shadowban.Config, watchdogs ...detect.Watchdog) *harness { +func newHarness(t *testing.T, config jury.Config, ban bans.Config, watchdogs ...detect.Watchdog) *harness { t.Helper() h := &harness{t: t, clock: cptime.NewFixedClock(time.Date(2026, 9, 11, 12, 0, 0, 0, time.UTC))} - banner := shadowban.NewBans(ban, h.clock, shadowban.NewMemoryStore(), shadowban.NewMemoryStore()) - h.jury = jury.New(config, banner, h.clock, jury.Hooks{ + h.banner = bans.NewBook(ban, h.clock, bans.NewMemoryStore(), bans.NewMemoryStore()) + h.jury = jury.New(config, h.banner, h.clock, jury.Hooks{ OnFlag: func(report detect.Report) { h.reports = append(h.reports, report) }, @@ -69,9 +70,12 @@ func newHarness(t *testing.T, config jury.Config, ban shadowban.Config, watchdog func (h *harness) inspect(click detect.Click) bool { h.t.Helper() - drop, err := h.jury.Inspect(h.t.Context(), click) + require.NoError(h.t, h.jury.Inspect(h.t.Context(), click)) + + _, banned, err := h.banner.EnforcedSentence(h.t.Context(), + bans.Caller{Scope: click.Scope, Account: click.Account, SignedIn: click.SignedIn}) require.NoError(h.t, err) - return drop + return banned } func (h *harness) flagged() int { @@ -102,8 +106,8 @@ func juryConfig() jury.Config { } } -func banConfig() shadowban.Config { - return shadowban.Config{ +func banConfig() bans.Config { + return bans.Config{ Enforce: true, BanDurations: []time.Duration{time.Hour}, ReflagInterval: 5 * time.Minute, @@ -566,17 +570,16 @@ func TestAGuestAccompliceTakesItsScopeWithIt(t *testing.T) { "a guest sheds its account with a new cookie, so its ban falls on its scope too") } -func TestABanThatCannotBeReadLetsTheClickThrough(t *testing.T) { +func TestAFlagThatCannotBeKeptIsReported(t *testing.T) { clock := cptime.NewFixedClock(time.Date(2026, 10, 8, 12, 0, 0, 0, time.UTC)) - store := shadowban.NewMemoryStore() + store := bans.NewMemoryStore() cause := errors.New("postgres is down") store.FailWith(cause) - j := jury.New(juryConfig(), shadowban.NewBans(banConfig(), clock, store, store), clock, jury.Hooks{}, + j := jury.New(juryConfig(), bans.NewBook(banConfig(), clock, store, store), clock, jury.Hooks{}, &stubWatchdog{name: "sure", verdict: detect.Certain}) - drop, err := j.Inspect(t.Context(), detect.Click{Scope: "caller", Tile: 1, Country: "FR", At: clock.Now()}) + err := j.Inspect(t.Context(), detect.Click{Scope: "caller", Tile: 1, Country: "FR", At: clock.Now()}) require.ErrorIs(t, err, cause) - assert.False(t, drop) } diff --git a/apps/backend/internal/antibot/internal/jury/state_test.go b/apps/backend/internal/antibot/internal/jury/state_test.go index 231716395..5389ee27f 100644 --- a/apps/backend/internal/antibot/internal/jury/state_test.go +++ b/apps/backend/internal/antibot/internal/jury/state_test.go @@ -7,8 +7,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/raphoester/clickplanet.lol-backend/internal/antibot/internal/bans" "github.com/raphoester/clickplanet.lol-backend/internal/antibot/internal/detect" - "github.com/raphoester/clickplanet.lol-backend/internal/antibot/internal/shadowban" "github.com/raphoester/clickplanet.lol-backend/internal/shared/cptime" ) @@ -23,7 +23,7 @@ func (s *saying) Committed(detect.Click) {} func (s *saying) Watch(detect.Click) (detect.Verdict, detect.Evidence) { return s.verdict, s.evidence } func newJury(clock cptime.Clock, hooks Hooks, watchdog detect.Watchdog) *Jury { - banner := shadowban.NewBans(shadowban.Config{Enforce: true}, clock, shadowban.NewMemoryStore(), shadowban.NewMemoryStore()) + banner := bans.NewBook(bans.Config{Enforce: true}, clock, bans.NewMemoryStore(), bans.NewMemoryStore()) return New(Config{TrackWindow: time.Hour}, banner, clock, hooks, watchdog) } @@ -69,7 +69,7 @@ func TestTheCallerAndItsOpinionsSurviveASaveAndLoad(t *testing.T) { clock.Advance(time.Second) watchdog.verdict = detect.Certain - require.True(t, inspect(t, restarted, detect.Click{Scope: "caller", Tile: 20, Country: "FR", At: clock.Now()})) + inspect(t, restarted, detect.Click{Scope: "caller", Tile: 20, Country: "FR", At: clock.Now()}) require.Len(t, reports, 1) assert.Equal(t, []detect.Verdict{detect.Certain}, rises, "suspect was already standing before the restart") diff --git a/apps/backend/internal/antibot/internal/jury/sweep_test.go b/apps/backend/internal/antibot/internal/jury/sweep_test.go index 857d4e0ee..d579d9f4a 100644 --- a/apps/backend/internal/antibot/internal/jury/sweep_test.go +++ b/apps/backend/internal/antibot/internal/jury/sweep_test.go @@ -5,8 +5,8 @@ import ( "testing" "time" + "github.com/raphoester/clickplanet.lol-backend/internal/antibot/internal/bans" "github.com/raphoester/clickplanet.lol-backend/internal/antibot/internal/detect" - "github.com/raphoester/clickplanet.lol-backend/internal/antibot/internal/shadowban" "github.com/raphoester/clickplanet.lol-backend/internal/shared/cptime" "github.com/stretchr/testify/assert" @@ -30,19 +30,15 @@ func (w *fixedWatchdog) Watch(detect.Click) (detect.Verdict, detect.Evidence) { func (w *fixedWatchdog) Committed(detect.Click) {} -func (stubBanner) Flag(context.Context, shadowban.Caller) (shadowban.Sentence, bool, error) { - return shadowban.Sentence{Flags: 1}, true, nil +func (stubBanner) Flag(context.Context, bans.Caller) (bans.Sentence, bool, error) { + return bans.Sentence{Flags: 1}, true, nil } -func (stubBanner) Banned(context.Context, shadowban.Caller) (bool, error) { return false, nil } - func (stubBanner) Flagged(context.Context) (int, error) { return 0, nil } -func inspect(t *testing.T, j *Jury, click detect.Click) bool { +func inspect(t *testing.T, j *Jury, click detect.Click) { t.Helper() - drop, err := j.Inspect(t.Context(), click) - require.NoError(t, err) - return drop + require.NoError(t, j.Inspect(t.Context(), click)) } func TestSweepForgetsIdleCallers(t *testing.T) { diff --git a/apps/backend/internal/antibot/internal/postgres_ban_store/store.go b/apps/backend/internal/antibot/internal/postgres_ban_store/store.go index 44991a029..512e84dd3 100644 --- a/apps/backend/internal/antibot/internal/postgres_ban_store/store.go +++ b/apps/backend/internal/antibot/internal/postgres_ban_store/store.go @@ -7,11 +7,11 @@ import ( "fmt" "time" - "github.com/raphoester/clickplanet.lol-backend/internal/antibot/internal/shadowban" + "github.com/raphoester/clickplanet.lol-backend/internal/antibot/internal/bans" "github.com/raphoester/clickplanet.lol-backend/internal/shared/cppg" ) -var _ shadowban.Store = (*Store)(nil) +var _ bans.Store = (*Store)(nil) const ( scopesLock = 0x62616e73 @@ -60,12 +60,12 @@ type queries struct { save string } -func (s *Store) Record(ctx context.Context, key string) (shadowban.Record, bool, error) { +func (s *Store) Record(ctx context.Context, key string) (bans.Record, bool, error) { return s.recordOf(ctx, s.db, key) } -func (s *Store) recordOf(ctx context.Context, db cppg.Querier, key string) (shadowban.Record, bool, error) { - record := shadowban.Record{Key: key} +func (s *Store) recordOf(ctx context.Context, db cppg.Querier, key string) (bans.Record, bool, error) { + record := bans.Record{Key: key} var flagged sql.NullTime err := db.QueryRowContext(ctx, s.queries.record, key).Scan(&record.Flags, &record.Offences, &record.ExpiresAt, &flagged) @@ -73,7 +73,7 @@ func (s *Store) recordOf(ctx context.Context, db cppg.Querier, key string) (shad return record, false, nil } if err != nil { - return shadowban.Record{}, false, fmt.Errorf("failed to read the ban: %w", err) + return bans.Record{}, false, fmt.Errorf("failed to read the ban: %w", err) } record.ExpiresAt = record.ExpiresAt.UTC() @@ -95,7 +95,7 @@ func (s *Store) Running(ctx context.Context, now time.Time) (int, error) { func (s *Store) Change( ctx context.Context, key string, - change func(record shadowban.Record, found bool) (shadowban.Record, bool), + change func(record bans.Record, found bool) (bans.Record, bool), ) (err error) { tx, err := s.db.BeginTx(ctx, &sql.TxOptions{}) if err != nil { diff --git a/apps/backend/internal/antibot/internal/postgres_ban_store/store_test.go b/apps/backend/internal/antibot/internal/postgres_ban_store/store_test.go index 43a17122f..943a294af 100644 --- a/apps/backend/internal/antibot/internal/postgres_ban_store/store_test.go +++ b/apps/backend/internal/antibot/internal/postgres_ban_store/store_test.go @@ -7,9 +7,9 @@ import ( "github.com/stretchr/testify/suite" + "github.com/raphoester/clickplanet.lol-backend/internal/antibot/internal/bans" "github.com/raphoester/clickplanet.lol-backend/internal/antibot/internal/migrations" "github.com/raphoester/clickplanet.lol-backend/internal/antibot/internal/postgres_ban_store" - "github.com/raphoester/clickplanet.lol-backend/internal/antibot/internal/shadowban" "github.com/raphoester/clickplanet.lol-backend/internal/shared/cppg" ) @@ -31,15 +31,15 @@ func (s *testSuite) purge() { } func (s *testSuite) TestScopesKeepTheContract() { - suite.Run(s.T(), &shadowban.StoreContractSuite{ - NewStore: func() shadowban.Store { s.purge(); return postgres_ban_store.NewScopes(s.db) }, + suite.Run(s.T(), &bans.StoreContractSuite{ + NewStore: func() bans.Store { s.purge(); return postgres_ban_store.NewScopes(s.db) }, Key: func(n int) string { return fmt.Sprintf("2001:db8:%x::/64", n) }, }) } func (s *testSuite) TestAccountsKeepTheContract() { - suite.Run(s.T(), &shadowban.StoreContractSuite{ - NewStore: func() shadowban.Store { s.purge(); return postgres_ban_store.NewAccounts(s.db) }, + suite.Run(s.T(), &bans.StoreContractSuite{ + NewStore: func() bans.Store { s.purge(); return postgres_ban_store.NewAccounts(s.db) }, Key: func(n int) string { return fmt.Sprintf("0b7e5b6c-8f3a-4d2e-9c1a-%012x", n) }, }) } @@ -52,7 +52,7 @@ func (s *testSuite) TestAccountsAreKeptApartFromScopes() { scopes, accounts := postgres_ban_store.NewScopes(s.db), postgres_ban_store.NewAccounts(s.db) const account = "0b7e5b6c-8f3a-4d2e-9c1a-2f6d8e4b7a10" - s.Require().NoError(accounts.Change(ctx, account, func(record shadowban.Record, _ bool) (shadowban.Record, bool) { + s.Require().NoError(accounts.Change(ctx, account, func(record bans.Record, _ bool) (bans.Record, bool) { record.Offences, record.ExpiresAt = 1, until return record, true })) @@ -70,7 +70,7 @@ func (s *testSuite) TestAKeyThatIsNotAnAccountIsAnError() { s.purge() accounts := postgres_ban_store.NewAccounts(s.db) - s.Require().Error(accounts.Change(s.T().Context(), "1.2.3.4", func(record shadowban.Record, _ bool) (shadowban.Record, bool) { + s.Require().Error(accounts.Change(s.T().Context(), "1.2.3.4", func(record bans.Record, _ bool) (bans.Record, bool) { return record, true })) diff --git a/apps/backend/internal/antibot/internal/shadowban/bans_test.go b/apps/backend/internal/antibot/internal/shadowban/bans_test.go deleted file mode 100644 index 33e391cf5..000000000 --- a/apps/backend/internal/antibot/internal/shadowban/bans_test.go +++ /dev/null @@ -1,131 +0,0 @@ -package shadowban_test - -import ( - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/raphoester/clickplanet.lol-backend/internal/antibot/internal/shadowban" - "github.com/raphoester/clickplanet.lol-backend/internal/shared/cptime" -) - -func newBans(t *testing.T, clock cptime.Clock) (bans, *shadowban.MemoryStore, *shadowban.MemoryStore) { - t.Helper() - - scopes, accounts := shadowban.NewMemoryStore(), shadowban.NewMemoryStore() - return bans{t: t, inner: shadowban.NewBans(config(), clock, scopes, accounts)}, scopes, accounts -} - -func TestAFlaggedGuestIsBannedOnItsAccountAndItsScope(t *testing.T) { - bans, _, _ := newBans(t, newClock()) - - _, accepted := bans.Flag(shadowban.Caller{Scope: "1.2.3.4", Account: "guest"}) - require.True(t, accepted) - - assert.True(t, bans.Banned(shadowban.Caller{Scope: "1.2.3.4", Account: "a-fresh-cookie"}), - "a new cookie on the same scope is still dropped") - assert.True(t, bans.Banned(shadowban.Caller{Scope: "5.6.7.8", Account: "guest"}), - "the same account on another scope is still dropped") - assert.False(t, bans.Banned(shadowban.Caller{Scope: "5.6.7.8", Account: "someone-else"})) - assert.Equal(t, 2, bans.Flagged(), "one guest, banned twice") -} - -func TestAFlaggedSignedInAccountLeavesItsScopeAlone(t *testing.T) { - bans, _, _ := newBans(t, newClock()) - - _, accepted := bans.Flag(shadowban.Caller{Scope: "campus", Account: "player", SignedIn: true}) - require.True(t, accepted) - - assert.True(t, bans.Banned(shadowban.Caller{Scope: "home", Account: "player", SignedIn: true})) - assert.False(t, bans.Banned(shadowban.Caller{Scope: "campus", Account: "classmate", SignedIn: true}), - "the other players behind the campus address are not banned with it") -} - -func TestAFlagWithNoAccountBansTheScope(t *testing.T) { - bans, _, _ := newBans(t, newClock()) - - _, accepted := bans.Flag(shadowban.Caller{Scope: "1.2.3.4"}) - require.True(t, accepted) - - assert.True(t, bans.Banned(shadowban.Caller{Scope: "1.2.3.4"})) - assert.True(t, bans.Banned(shadowban.Caller{Scope: "1.2.3.4", Account: "guest"})) -} - -func TestAnOperatorBanOnAnAccountBansTheAccountAlone(t *testing.T) { - clock := newClock() - bans, _, _ := newBans(t, clock) - - sentence := bans.Ban(shadowban.Caller{Account: "guest"}, 0) - assert.Equal(t, 1, sentence.Offence) - assert.Equal(t, clock.Now().Add(time.Hour), sentence.Until) - - assert.True(t, bans.Banned(shadowban.Caller{Scope: "1.2.3.4", Account: "guest"})) - assert.False(t, bans.Banned(shadowban.Caller{Scope: "1.2.3.4"})) -} - -func TestTheSentenceIsTheBanThatEndsLast(t *testing.T) { - clock := newClock() - bans, _, _ := newBans(t, clock) - - bans.Ban(shadowban.Caller{Scope: "1.2.3.4"}, time.Hour) - bans.Ban(shadowban.Caller{Account: "guest"}, 2*time.Hour) - - sentence, running := bans.Sentence(shadowban.Caller{Scope: "1.2.3.4", Account: "guest"}) - require.True(t, running) - assert.Equal(t, clock.Now().Add(2*time.Hour), sentence.Until) - - _, running = bans.Sentence(shadowban.Caller{Scope: "5.6.7.8", Account: "someone-else"}) - assert.False(t, running) -} - -func TestAccountBansAreKeptApart(t *testing.T) { - clock := newClock() - before, scopes, accounts := newBans(t, clock) - - before.Flag(shadowban.Caller{Scope: "1.2.3.4", Account: "guest"}) - - assert.Contains(t, scopes.Stored(), "1.2.3.4") - assert.NotContains(t, scopes.Stored(), "guest") - assert.Contains(t, accounts.Stored(), "guest") - - after := bans{t: t, inner: shadowban.NewBans(config(), clock, scopes, accounts)} - assert.True(t, after.Banned(shadowban.Caller{Scope: "5.6.7.8", Account: "guest"})) -} - -func TestAnUnbanLiftsOnlyTheBanItNames(t *testing.T) { - bans, _, _ := newBans(t, newClock()) - guest := shadowban.Caller{Scope: "1.2.3.4", Account: "guest"} - - bans.Flag(guest) - - bans.Unban(shadowban.Caller{Account: "guest"}) - assert.True(t, bans.Banned(guest), "the guest's scope is still banned") - assert.False(t, bans.Banned(shadowban.Caller{Scope: "5.6.7.8", Account: "guest"})) - - bans.Unban(shadowban.Caller{Scope: "1.2.3.4"}) - assert.False(t, bans.Banned(guest)) - assert.Equal(t, 0, bans.Flagged()) -} - -func TestTheLastSentenceOfAnAccountIsReadAfterItsBanEnded(t *testing.T) { - clock := newClock() - bans, _, accounts := newBans(t, clock) - player := shadowban.Caller{Account: "player"} - - banned := bans.Ban(player, 0) - clock.Advance(2 * time.Hour) - - _, running := bans.Sentence(player) - require.False(t, running) - - last, found := bans.LastSentence(player) - require.True(t, found) - assert.Equal(t, banned, last) - - bans.Unban(player) - _, found = bans.LastSentence(player) - assert.False(t, found) - assert.Equal(t, 0, accounts.Stored()["player"].Offences) -} diff --git a/apps/backend/internal/planet/internal/bonuses/usecases/drop_bomb_usecase/antibot_drop_bomb/usecase.go b/apps/backend/internal/planet/internal/bonuses/usecases/drop_bomb_usecase/antibot_drop_bomb/usecase.go index 0b685a40f..47fc16fa9 100644 --- a/apps/backend/internal/planet/internal/bonuses/usecases/drop_bomb_usecase/antibot_drop_bomb/usecase.go +++ b/apps/backend/internal/planet/internal/bonuses/usecases/drop_bomb_usecase/antibot_drop_bomb/usecase.go @@ -3,6 +3,7 @@ package antibot_drop_bomb import ( "context" + "github.com/raphoester/clickplanet.lol-backend/internal/antibot" "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/bonuses/usecases/drop_bomb_usecase" "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/clicks" ) @@ -12,7 +13,7 @@ type UseCase interface { } type Bans interface { - Banned(ctx context.Context, scope, account string) bool + EnforcedSentence(ctx context.Context, scope, account string) (antibot.Sentence, bool) } func New(implementation UseCase, bans Bans) *Decorator { @@ -24,10 +25,11 @@ type Decorator struct { bans Bans } -// Still runs the drop: a bomb left in hand would tell a banned caller it was refused. func (d *Decorator) Execute(ctx context.Context, in drop_bomb_usecase.In) (clicks.Blast, error) { payer := clicks.PayerOf(ctx) - in.Dud = d.bans.Banned(ctx, payer.Scope, payer.Account) + if sentence, banned := d.bans.EnforcedSentence(ctx, payer.Scope, payer.Account); banned { + return clicks.Blast{}, clicks.NewBanRefusal(sentence.Until) + } - return d.implementation.Execute(ctx, in) //nolint:wrapcheck // a decorator adds a flag, not a sentence. + return d.implementation.Execute(ctx, in) //nolint:wrapcheck // a decorator adds a gate, not a sentence. } diff --git a/apps/backend/internal/planet/internal/bonuses/usecases/drop_bomb_usecase/antibot_drop_bomb/usecase_test.go b/apps/backend/internal/planet/internal/bonuses/usecases/drop_bomb_usecase/antibot_drop_bomb/usecase_test.go index c12490ec6..897c30836 100644 --- a/apps/backend/internal/planet/internal/bonuses/usecases/drop_bomb_usecase/antibot_drop_bomb/usecase_test.go +++ b/apps/backend/internal/planet/internal/bonuses/usecases/drop_bomb_usecase/antibot_drop_bomb/usecase_test.go @@ -3,57 +3,73 @@ package antibot_drop_bomb_test import ( "context" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/raphoester/clickplanet.lol-backend/internal/antibot" "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/bonuses/usecases/drop_bomb_usecase" "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/bonuses/usecases/drop_bomb_usecase/antibot_drop_bomb" "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/clicks" - "github.com/raphoester/clickplanet.lol-backend/internal/shared/cpcolls" "github.com/raphoester/clickplanet.lol-backend/internal/shared/cpctx" ) -type stubUseCase struct{ in drop_bomb_usecase.In } +var until = time.Date(2026, 10, 21, 21, 40, 0, 0, time.UTC) -func (s *stubUseCase) Execute(_ context.Context, in drop_bomb_usecase.In) (clicks.Blast, error) { - s.in = in - return clicks.Blast{}, nil +type stubUseCase struct{ ran bool } + +func (s *stubUseCase) Execute(context.Context, drop_bomb_usecase.In) (clicks.Blast, error) { + s.ran = true + return clicks.Blast{Tile: 7}, nil } -type bans struct{ scopes, accounts *cpcolls.Set[string] } +type bans map[string]time.Time + +func (b bans) EnforcedSentence(_ context.Context, scope, account string) (antibot.Sentence, bool) { + for _, key := range []string{scope, account} { + if end, ok := b[key]; ok { + return antibot.Sentence{Until: end}, true + } + } + return antibot.Sentence{}, false +} -func (b bans) Banned(_ context.Context, scope, account string) bool { - return b.scopes.Contains(scope) || b.accounts.Contains(account) +func requireRefusedUntil(t *testing.T, err error) { + t.Helper() + var refusal clicks.BanRefusal + require.ErrorAs(t, err, &refusal) + assert.Equal(t, until, refusal.Until()) } -func TestABannedCallersBombIsADud(t *testing.T) { +func TestABannedCallerIsRefusedAndKeepsItsBomb(t *testing.T) { inner := &stubUseCase{} - decorator := antibot_drop_bomb.New(inner, bans{scopes: cpcolls.NewSet("2001:db8::/64")}) + decorator := antibot_drop_bomb.New(inner, bans{"2001:db8::/64": until}) _, err := decorator.Execute(cpctx.AddIPToContext(t.Context(), "2001:db8::9"), drop_bomb_usecase.In{CountryID: "fr"}) - require.NoError(t, err) - assert.True(t, inner.in.Dud, "the ban is on the scope, not the address") + requireRefusedUntil(t, err) + assert.False(t, inner.ran, "the ban is on the scope, not the address, and nothing is spent") } -func TestAnyoneElsesBombIsReal(t *testing.T) { +func TestABannedAccountIsRefusedFromAnyScope(t *testing.T) { inner := &stubUseCase{} - decorator := antibot_drop_bomb.New(inner, bans{scopes: cpcolls.NewSet("2001:db8::/64")}) + decorator := antibot_drop_bomb.New(inner, bans{"a-guest": until}) - _, err := decorator.Execute(cpctx.AddIPToContext(t.Context(), "203.0.113.7"), drop_bomb_usecase.In{CountryID: "fr"}) - require.NoError(t, err) + ctx := cpctx.AddAccountToContext(cpctx.AddIPToContext(t.Context(), "203.0.113.7"), "a-guest") + _, err := decorator.Execute(ctx, drop_bomb_usecase.In{CountryID: "fr"}) - assert.False(t, inner.in.Dud) + requireRefusedUntil(t, err) + assert.False(t, inner.ran) } -func TestABannedAccountsBombIsADudFromAnyScope(t *testing.T) { +func TestAnyoneElsesBombDrops(t *testing.T) { inner := &stubUseCase{} - decorator := antibot_drop_bomb.New(inner, bans{accounts: cpcolls.NewSet("a-guest")}) + decorator := antibot_drop_bomb.New(inner, bans{"2001:db8::/64": until}) - ctx := cpctx.AddAccountToContext(cpctx.AddIPToContext(t.Context(), "203.0.113.7"), "a-guest") - _, err := decorator.Execute(ctx, drop_bomb_usecase.In{CountryID: "fr"}) - require.NoError(t, err) + blast, err := decorator.Execute(cpctx.AddIPToContext(t.Context(), "203.0.113.7"), drop_bomb_usecase.In{CountryID: "fr"}) - assert.True(t, inner.in.Dud) + require.NoError(t, err) + assert.True(t, inner.ran) + assert.Equal(t, uint32(7), blast.Tile) } diff --git a/apps/backend/internal/planet/internal/bonuses/usecases/drop_bomb_usecase/prom_drop_bomb/usecase.go b/apps/backend/internal/planet/internal/bonuses/usecases/drop_bomb_usecase/prom_drop_bomb/usecase.go index d90b733e2..a7df810a6 100644 --- a/apps/backend/internal/planet/internal/bonuses/usecases/drop_bomb_usecase/prom_drop_bomb/usecase.go +++ b/apps/backend/internal/planet/internal/bonuses/usecases/drop_bomb_usecase/prom_drop_bomb/usecase.go @@ -2,6 +2,7 @@ package prom_drop_bomb import ( "context" + "errors" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" @@ -18,7 +19,7 @@ func New(implementation UseCase, registerer prometheus.Registerer) *Decorator { drops := factory.NewCounterVec(prometheus.CounterOpts{ Name: "bonus_bombs_dropped_total", - Help: "Bomb drops, by outcome: land, sea, refused or shadowbanned", + Help: "Bomb drops, by outcome: land, sea, banned or refused", }, []string{"outcome"}) cleared := factory.NewCounter(prometheus.CounterOpts{ @@ -39,10 +40,10 @@ func (d *Decorator) Execute(ctx context.Context, in drop_bomb_usecase.In) (click blast, err := d.implementation.Execute(ctx, in) switch { + case errors.Is(err, clicks.ErrBanned): + d.drops.WithLabelValues("banned").Inc() case err != nil: d.drops.WithLabelValues("refused").Inc() - case in.Dud: - d.drops.WithLabelValues("shadowbanned").Inc() case blast.Tile == 0: d.drops.WithLabelValues("sea").Inc() default: diff --git a/apps/backend/internal/planet/internal/bonuses/usecases/drop_bomb_usecase/publishing_drop_bomb/usecase.go b/apps/backend/internal/planet/internal/bonuses/usecases/drop_bomb_usecase/publishing_drop_bomb/usecase.go index ccb908651..6731b6afb 100644 --- a/apps/backend/internal/planet/internal/bonuses/usecases/drop_bomb_usecase/publishing_drop_bomb/usecase.go +++ b/apps/backend/internal/planet/internal/bonuses/usecases/drop_bomb_usecase/publishing_drop_bomb/usecase.go @@ -37,7 +37,7 @@ type Decorator struct { func (d *Decorator) Execute(ctx context.Context, in drop_bomb_usecase.In) (clicks.Blast, error) { blast, err := d.implementation.Execute(ctx, in) - if err != nil || in.Dud { + if err != nil { return blast, err //nolint:wrapcheck // a decorator adds an event, not a sentence. } diff --git a/apps/backend/internal/planet/internal/bonuses/usecases/drop_bomb_usecase/publishing_drop_bomb/usecase_test.go b/apps/backend/internal/planet/internal/bonuses/usecases/drop_bomb_usecase/publishing_drop_bomb/usecase_test.go index 9fe6e405d..dd65b6b9c 100644 --- a/apps/backend/internal/planet/internal/bonuses/usecases/drop_bomb_usecase/publishing_drop_bomb/usecase_test.go +++ b/apps/backend/internal/planet/internal/bonuses/usecases/drop_bomb_usecase/publishing_drop_bomb/usecase_test.go @@ -60,7 +60,7 @@ func TestABombInTheSeaIsPublishedWithNoGround(t *testing.T) { assert.True(t, proto.Equal(&planetv1.BombLanded{Country: "fr", LandedAt: timestamppb.New(now)}, events.Published()[0])) } -func TestARefusedDropOrADudPublishesNothing(t *testing.T) { +func TestARefusedDropPublishesNothing(t *testing.T) { events := cpbootstrap.NewRecordedEvents() refused := errors.New("no bomb") @@ -68,9 +68,5 @@ func TestARefusedDropOrADudPublishesNothing(t *testing.T) { Execute(t.Context(), drop_bomb_usecase.In{CountryID: "fr"}) require.ErrorIs(t, err, refused) - _, err = publishing_drop_bomb.New(stubUseCase{}, borders{}, events, cptime.NewFixedClock(now)). - Execute(t.Context(), drop_bomb_usecase.In{CountryID: "fr", Dud: true}) - require.NoError(t, err) - assert.Empty(t, events.Published()) } diff --git a/apps/backend/internal/planet/internal/bonuses/usecases/drop_bomb_usecase/usecase.go b/apps/backend/internal/planet/internal/bonuses/usecases/drop_bomb_usecase/usecase.go index 8c09cbbf0..a6f0b9802 100644 --- a/apps/backend/internal/planet/internal/bonuses/usecases/drop_bomb_usecase/usecase.go +++ b/apps/backend/internal/planet/internal/bonuses/usecases/drop_bomb_usecase/usecase.go @@ -31,8 +31,6 @@ type CountryChecker interface { type In struct { Target clicks.Vec3 CountryID string - - Dud bool } func New(bombs Bombs, geography Map, clearer Clearer, countries CountryChecker, rules bonuses.BombRules) *UseCase { @@ -67,10 +65,6 @@ func (u *UseCase) Execute(ctx context.Context, in In) (clicks.Blast, error) { return clicks.Blast{}, ErrNoBomb } - if in.Dud { - return clicks.Blast{}, nil - } - blast, err := u.clearer.Clear(ctx, u.rules.Blast(in.CountryID, in.Target, tile, arc, u.geography)) if err != nil { return clicks.Blast{}, fmt.Errorf("failed to clear the blast at tile %d: %w", tile, err) diff --git a/apps/backend/internal/planet/internal/bonuses/usecases/drop_bomb_usecase/usecase_test.go b/apps/backend/internal/planet/internal/bonuses/usecases/drop_bomb_usecase/usecase_test.go index 2d2961acb..43adce5ac 100644 --- a/apps/backend/internal/planet/internal/bonuses/usecases/drop_bomb_usecase/usecase_test.go +++ b/apps/backend/internal/planet/internal/bonuses/usecases/drop_bomb_usecase/usecase_test.go @@ -130,14 +130,3 @@ func TestAMalformedDropDoesNotCostTheBomb(t *testing.T) { assert.True(t, p.bombs.held) assert.Empty(t, p.bombs.spent, "a malformed drop is refused before the bomb is touched") } - -func TestADudIsSpentAndClearsAndAnnouncesNothing(t *testing.T) { - useCase, p := setup(true, 0.001) - - blast, err := useCase.Execute(t.Context(), drop_bomb_usecase.In{Target: clicks.Vec3{X: 2}, CountryID: "fr", Dud: true}) - require.NoError(t, err) - - assert.Empty(t, blast.Cleared) - assert.Empty(t, p.clearer.cleared, "nobody sees a banned caller's bomb") - assert.False(t, p.bombs.held, "the bomb is gone, as it would be for anyone") -} diff --git a/apps/backend/internal/planet/internal/bonuses/usecases/place_shield_usecase/antibot_place_shield/usecase.go b/apps/backend/internal/planet/internal/bonuses/usecases/place_shield_usecase/antibot_place_shield/usecase.go index 3c3ac4b87..21c57269f 100644 --- a/apps/backend/internal/planet/internal/bonuses/usecases/place_shield_usecase/antibot_place_shield/usecase.go +++ b/apps/backend/internal/planet/internal/bonuses/usecases/place_shield_usecase/antibot_place_shield/usecase.go @@ -3,6 +3,7 @@ package antibot_place_shield import ( "context" + "github.com/raphoester/clickplanet.lol-backend/internal/antibot" "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/bonuses" "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/bonuses/usecases/place_shield_usecase" "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/clicks" @@ -13,7 +14,7 @@ type UseCase interface { } type Bans interface { - Banned(ctx context.Context, scope, account string) bool + EnforcedSentence(ctx context.Context, scope, account string) (antibot.Sentence, bool) } func New(implementation UseCase, bans Bans) *Decorator { @@ -25,10 +26,11 @@ type Decorator struct { bans Bans } -// Still spends the shield: one left in hand would tell a banned caller it was refused. func (d *Decorator) Execute(ctx context.Context, in place_shield_usecase.In) (bonuses.Held, error) { payer := clicks.PayerOf(ctx) - in.Dud = d.bans.Banned(ctx, payer.Scope, payer.Account) + if sentence, banned := d.bans.EnforcedSentence(ctx, payer.Scope, payer.Account); banned { + return bonuses.Held{}, clicks.NewBanRefusal(sentence.Until) + } - return d.implementation.Execute(ctx, in) //nolint:wrapcheck // a decorator adds a flag, not a sentence. + return d.implementation.Execute(ctx, in) //nolint:wrapcheck // a decorator adds a gate, not a sentence. } diff --git a/apps/backend/internal/planet/internal/bonuses/usecases/place_shield_usecase/antibot_place_shield/usecase_test.go b/apps/backend/internal/planet/internal/bonuses/usecases/place_shield_usecase/antibot_place_shield/usecase_test.go index 9a3ae558e..94b73c717 100644 --- a/apps/backend/internal/planet/internal/bonuses/usecases/place_shield_usecase/antibot_place_shield/usecase_test.go +++ b/apps/backend/internal/planet/internal/bonuses/usecases/place_shield_usecase/antibot_place_shield/usecase_test.go @@ -3,57 +3,74 @@ package antibot_place_shield_test import ( "context" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/raphoester/clickplanet.lol-backend/internal/antibot" "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/bonuses" "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/bonuses/usecases/place_shield_usecase" "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/bonuses/usecases/place_shield_usecase/antibot_place_shield" - "github.com/raphoester/clickplanet.lol-backend/internal/shared/cpcolls" + "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/clicks" "github.com/raphoester/clickplanet.lol-backend/internal/shared/cpctx" ) -type stubUseCase struct{ in place_shield_usecase.In } +var until = time.Date(2026, 10, 21, 21, 40, 0, 0, time.UTC) -func (s *stubUseCase) Execute(_ context.Context, in place_shield_usecase.In) (bonuses.Held, error) { - s.in = in - return bonuses.Held{}, nil +type stubUseCase struct{ ran bool } + +func (s *stubUseCase) Execute(context.Context, place_shield_usecase.In) (bonuses.Held, error) { + s.ran = true + return bonuses.Held{Shields: 2}, nil } -type bans struct{ scopes, accounts *cpcolls.Set[string] } +type bans map[string]time.Time + +func (b bans) EnforcedSentence(_ context.Context, scope, account string) (antibot.Sentence, bool) { + for _, key := range []string{scope, account} { + if end, ok := b[key]; ok { + return antibot.Sentence{Until: end}, true + } + } + return antibot.Sentence{}, false +} -func (b bans) Banned(_ context.Context, scope, account string) bool { - return b.scopes.Contains(scope) || b.accounts.Contains(account) +func requireRefusedUntil(t *testing.T, err error) { + t.Helper() + var refusal clicks.BanRefusal + require.ErrorAs(t, err, &refusal) + assert.Equal(t, until, refusal.Until()) } -func TestABannedCallersShieldIsADud(t *testing.T) { +func TestABannedCallerIsRefusedAndKeepsItsShield(t *testing.T) { inner := &stubUseCase{} - decorator := antibot_place_shield.New(inner, bans{scopes: cpcolls.NewSet("2001:db8::/64"), accounts: cpcolls.NewSet[string]()}) + decorator := antibot_place_shield.New(inner, bans{"2001:db8::/64": until}) _, err := decorator.Execute(cpctx.AddIPToContext(t.Context(), "2001:db8::9"), place_shield_usecase.In{CountryID: "fr"}) - require.NoError(t, err) - assert.True(t, inner.in.Dud) + requireRefusedUntil(t, err) + assert.False(t, inner.ran, "nothing is spent") } -func TestABannedAccountsShieldIsADudFromAnyScope(t *testing.T) { +func TestABannedAccountIsRefusedFromAnyScope(t *testing.T) { inner := &stubUseCase{} - decorator := antibot_place_shield.New(inner, bans{scopes: cpcolls.NewSet[string](), accounts: cpcolls.NewSet("a-guest")}) + decorator := antibot_place_shield.New(inner, bans{"a-guest": until}) ctx := cpctx.AddAccountToContext(cpctx.AddIPToContext(t.Context(), "203.0.113.7"), "a-guest") _, err := decorator.Execute(ctx, place_shield_usecase.In{CountryID: "fr"}) - require.NoError(t, err) - assert.True(t, inner.in.Dud) + requireRefusedUntil(t, err) + assert.False(t, inner.ran) } -func TestAnyoneElsesShieldIsReal(t *testing.T) { +func TestAnyoneElsePlacesTheShield(t *testing.T) { inner := &stubUseCase{} - decorator := antibot_place_shield.New(inner, bans{scopes: cpcolls.NewSet("2001:db8::/64"), accounts: cpcolls.NewSet[string]()}) + decorator := antibot_place_shield.New(inner, bans{"2001:db8::/64": until}) - _, err := decorator.Execute(cpctx.AddIPToContext(t.Context(), "203.0.113.7"), place_shield_usecase.In{CountryID: "fr"}) - require.NoError(t, err) + held, err := decorator.Execute(cpctx.AddIPToContext(t.Context(), "203.0.113.7"), place_shield_usecase.In{CountryID: "fr"}) - assert.False(t, inner.in.Dud) + require.NoError(t, err) + assert.True(t, inner.ran) + assert.Equal(t, 2, held.Shields) } diff --git a/apps/backend/internal/planet/internal/bonuses/usecases/place_shield_usecase/usecase.go b/apps/backend/internal/planet/internal/bonuses/usecases/place_shield_usecase/usecase.go index 99a5c2868..6d5267525 100644 --- a/apps/backend/internal/planet/internal/bonuses/usecases/place_shield_usecase/usecase.go +++ b/apps/backend/internal/planet/internal/bonuses/usecases/place_shield_usecase/usecase.go @@ -30,8 +30,6 @@ type CountryChecker interface { type In struct { TileID uint32 CountryID string - - Dud bool } func New(shields Shields, tiles Tiles, countries CountryChecker, perTile int) *UseCase { @@ -64,11 +62,9 @@ func (u *UseCase) Execute(ctx context.Context, in In) (bonuses.Held, error) { return bonuses.Held{}, ErrNoShield } - if !in.Dud { - if err := u.tiles.Shield(ctx, in.TileID, in.CountryID, u.perTile); err != nil { - u.shields.Grant(holder, bonuses.KindShields, 1) - return bonuses.Held{}, fmt.Errorf("failed to place the shield: %w", err) - } + if err := u.tiles.Shield(ctx, in.TileID, in.CountryID, u.perTile); err != nil { + u.shields.Grant(holder, bonuses.KindShields, 1) + return bonuses.Held{}, fmt.Errorf("failed to place the shield: %w", err) } return u.shields.Held(holder), nil diff --git a/apps/backend/internal/planet/internal/bonuses/usecases/place_shield_usecase/usecase_test.go b/apps/backend/internal/planet/internal/bonuses/usecases/place_shield_usecase/usecase_test.go index ef586a176..9b0f4351d 100644 --- a/apps/backend/internal/planet/internal/bonuses/usecases/place_shield_usecase/usecase_test.go +++ b/apps/backend/internal/planet/internal/bonuses/usecases/place_shield_usecase/usecase_test.go @@ -114,16 +114,6 @@ func TestAMalformedRequestIsRefused(t *testing.T) { assert.Equal(t, 3, f.charges.Held(caller).Shields) } -func TestADudSpendsTheShieldAndPlacesNothing(t *testing.T) { - f := setup(t, 3) - - held, err := f.place(t, place_shield_usecase.In{TileID: 7, CountryID: "fr", Dud: true}) - require.NoError(t, err) - - assert.Equal(t, 2, held.Shields) - assert.Zero(t, f.tiles.Shields(7)) -} - type lostRace struct{ *inmemory_tile_storage.Storage } func (l lostRace) Shield(context.Context, uint32, string, int) error { return clicks.ErrNotYourTile } diff --git a/apps/backend/internal/planet/internal/clicks/ban_refusal.go b/apps/backend/internal/planet/internal/clicks/ban_refusal.go new file mode 100644 index 000000000..f558f7ab5 --- /dev/null +++ b/apps/backend/internal/planet/internal/clicks/ban_refusal.go @@ -0,0 +1,23 @@ +package clicks + +import ( + "errors" + "fmt" + "time" +) + +var ErrBanned = errors.New("the caller is banned") + +func NewBanRefusal(until time.Time) BanRefusal { return BanRefusal{until: until} } + +type BanRefusal struct { + until time.Time +} + +func (r BanRefusal) Error() string { + return fmt.Sprintf("%s until %s", ErrBanned, r.until.UTC().Format(time.RFC3339)) +} + +func (r BanRefusal) Unwrap() error { return ErrBanned } + +func (r BanRefusal) Until() time.Time { return r.until } diff --git a/apps/backend/internal/planet/internal/clicks/usecases/click_usecase/antibot_click/observer.go b/apps/backend/internal/planet/internal/clicks/usecases/click_usecase/antibot_click/observer.go index cb11131f8..8dea71d46 100644 --- a/apps/backend/internal/planet/internal/clicks/usecases/click_usecase/antibot_click/observer.go +++ b/apps/backend/internal/planet/internal/clicks/usecases/click_usecase/antibot_click/observer.go @@ -83,7 +83,7 @@ func NewObserver(logger *slog.Logger, registerer prometheus.Registerer) antibot. }) flags := factory.NewCounterVec(prometheus.CounterOpts{ - Name: "shadowban_flags", + Name: "ban_flags", Help: "Times a caller has been flagged, counted once per watchdog that argued for it", }, []string{"watchdog"}) diff --git a/apps/backend/internal/planet/internal/clicks/usecases/click_usecase/antibot_click/usecase.go b/apps/backend/internal/planet/internal/clicks/usecases/click_usecase/antibot_click/usecase.go index d2030c972..89afee5bc 100644 --- a/apps/backend/internal/planet/internal/clicks/usecases/click_usecase/antibot_click/usecase.go +++ b/apps/backend/internal/planet/internal/clicks/usecases/click_usecase/antibot_click/usecase.go @@ -16,7 +16,7 @@ import ( ) type ClickGuard interface { - Inspect(ctx context.Context, click antibot.Click) (drop bool) + Inspect(ctx context.Context, click antibot.Click) Committed(click antibot.Click) Flagged(ctx context.Context) (int, error) } @@ -43,16 +43,9 @@ func New( clock = cptime.SystemClock{} } - factory := promauto.With(registerer) - - dropped := factory.NewCounter(prometheus.CounterOpts{ - Name: "shadowbanned_clicks", - Help: "Clicks answered OK and dropped without touching the map", - }) - - factory.NewGaugeFunc(prometheus.GaugeOpts{ - Name: "shadowban_flagged", - Help: "Callers currently banned, whether or not shadowBan.enforce is on", + promauto.With(registerer).NewGaugeFunc(prometheus.GaugeOpts{ + Name: "banned_callers", + Help: "Callers currently banned, whether or not bans.enforce is on", }, func() float64 { ctx, cancel := context.WithTimeout(context.Background(), flaggedTimeout) defer cancel() @@ -70,7 +63,6 @@ func New( owner: owner, rule: rule, clock: clock, - dropped: dropped, } } @@ -80,7 +72,6 @@ type UseCase struct { owner TileOwner rule Rule clock cptime.Clock - dropped prometheus.Counter } func (u *UseCase) Execute(ctx context.Context, in click_usecase.In) (click_usecase.Out, error) { @@ -100,11 +91,7 @@ func (u *UseCase) Execute(ctx context.Context, in click_usecase.In) (click_useca observed.Shielded = outcome == clicks.Shielded } - if u.guard.Inspect(ctx, observed) { - // Answer like an accepted click: a refusal would tell the bot it was caught. - u.dropped.Inc() - return click_usecase.Out{}, nil - } + u.guard.Inspect(ctx, observed) out, err := u.implementation.Execute(ctx, in) diff --git a/apps/backend/internal/planet/internal/clicks/usecases/click_usecase/antibot_click/usecase_test.go b/apps/backend/internal/planet/internal/clicks/usecases/click_usecase/antibot_click/usecase_test.go index 887811b25..a74d15aea 100644 --- a/apps/backend/internal/planet/internal/clicks/usecases/click_usecase/antibot_click/usecase_test.go +++ b/apps/backend/internal/planet/internal/clicks/usecases/click_usecase/antibot_click/usecase_test.go @@ -19,15 +19,12 @@ import ( ) type fakeGuard struct { - drop bool - seen []antibot.Click committed []antibot.Click } -func (g *fakeGuard) Inspect(_ context.Context, click antibot.Click) bool { +func (g *fakeGuard) Inspect(_ context.Context, click antibot.Click) { g.seen = append(g.seen, click) - return g.drop } func (g *fakeGuard) Committed(click antibot.Click) { @@ -98,25 +95,16 @@ func executeOn( } func TestAntiBotClick(t *testing.T) { - t.Run("lets an unflagged click through to the map", func(t *testing.T) { - guard := &fakeGuard{drop: false} + t.Run("judges a click and lets it through to the map", func(t *testing.T) { + guard := &fakeGuard{} inner := &fakeClick{} require.NoError(t, execute(t, t.Context(), guard, fakeOwner{}, inner)) require.True(t, inner.ran) + require.Len(t, guard.seen, 1) require.Len(t, guard.committed, 1, "a click the use case accepted reached the map") }) - t.Run("answers a flagged click OK without touching the map", func(t *testing.T) { - guard := &fakeGuard{drop: true} - inner := &fakeClick{} - - require.NoError(t, execute(t, t.Context(), guard, fakeOwner{}, inner), - "a shadow ban must look exactly like success") - require.False(t, inner.ran, "the map must not be touched") - require.Empty(t, guard.committed, "a dropped click took no tile") - }) - t.Run("does not report a click the use case refused", func(t *testing.T) { guard := &fakeGuard{} inner := &fakeClick{err: errors.New("nope")} diff --git a/apps/backend/internal/planet/internal/clicks/usecases/click_usecase/ban_click/usecase.go b/apps/backend/internal/planet/internal/clicks/usecases/click_usecase/ban_click/usecase.go new file mode 100644 index 000000000..f6012e43e --- /dev/null +++ b/apps/backend/internal/planet/internal/clicks/usecases/click_usecase/ban_click/usecase.go @@ -0,0 +1,40 @@ +package ban_click + +import ( + "context" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" + "github.com/raphoester/clickplanet.lol-backend/internal/antibot" + "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/clicks" + "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/clicks/usecases/click_usecase" +) + +type Bans interface { + EnforcedSentence(ctx context.Context, scope, account string) (antibot.Sentence, bool) +} + +func New(implementation click_usecase.IUseCase, bans Bans, registerer prometheus.Registerer) *UseCase { + refused := promauto.With(registerer).NewCounter(prometheus.CounterOpts{ + Name: "banned_clicks", + Help: "Clicks refused because their caller is banned", + }) + + return &UseCase{implementation: implementation, bans: bans, refused: refused} +} + +type UseCase struct { + implementation click_usecase.IUseCase + bans Bans + refused prometheus.Counter +} + +func (u *UseCase) Execute(ctx context.Context, in click_usecase.In) (click_usecase.Out, error) { + payer := clicks.PayerOf(ctx) + if sentence, banned := u.bans.EnforcedSentence(ctx, payer.Scope, payer.Account); banned { + u.refused.Inc() + return click_usecase.Out{}, clicks.NewBanRefusal(sentence.Until) + } + + return u.implementation.Execute(ctx, in) //nolint:wrapcheck // a decorator adds a gate, not a sentence. +} diff --git a/apps/backend/internal/planet/internal/clicks/usecases/click_usecase/ban_click/usecase_test.go b/apps/backend/internal/planet/internal/clicks/usecases/click_usecase/ban_click/usecase_test.go new file mode 100644 index 000000000..963d94057 --- /dev/null +++ b/apps/backend/internal/planet/internal/clicks/usecases/click_usecase/ban_click/usecase_test.go @@ -0,0 +1,80 @@ +package ban_click_test + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/raphoester/clickplanet.lol-backend/internal/antibot" + "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/clicks" + "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/clicks/usecases/click_usecase" + "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/clicks/usecases/click_usecase/ban_click" + "github.com/raphoester/clickplanet.lol-backend/internal/shared/cpctx" +) + +var until = time.Date(2026, 10, 21, 21, 40, 0, 0, time.UTC) + +type bans map[string]time.Time + +func (b bans) EnforcedSentence(_ context.Context, scope, account string) (antibot.Sentence, bool) { + for _, key := range []string{scope, account} { + if end, ok := b[key]; ok { + return antibot.Sentence{Until: end}, true + } + } + return antibot.Sentence{}, false +} + +type fakeClick struct{ ran bool } + +func (c *fakeClick) Execute(context.Context, click_usecase.In) (click_usecase.Out, error) { + c.ran = true + return click_usecase.Out{Outcome: clicks.Taken}, nil +} + +func TestABannedScopeIsRefusedAndToldUntilWhen(t *testing.T) { + inner := &fakeClick{} + registry := prometheus.NewRegistry() + useCase := ban_click.New(inner, bans{"2001:db8::/64": until}, registry) + + _, err := useCase.Execute(cpctx.AddIPToContext(t.Context(), "2001:db8::9"), click_usecase.In{TileID: 42, CountryID: "fr"}) + + var refusal clicks.BanRefusal + require.ErrorAs(t, err, &refusal) + require.ErrorIs(t, err, clicks.ErrBanned) + assert.Equal(t, until, refusal.Until()) + assert.False(t, inner.ran, "nothing behind the gate runs: no token spent, no watchdog fed") + require.NoError(t, testutil.GatherAndCompare(registry, strings.NewReader(` +# HELP banned_clicks Clicks refused because their caller is banned +# TYPE banned_clicks counter +banned_clicks 1 +`))) +} + +func TestABannedAccountIsRefusedFromAnyScope(t *testing.T) { + inner := &fakeClick{} + useCase := ban_click.New(inner, bans{"a-guest": until}, prometheus.NewRegistry()) + + ctx := cpctx.AddAccountToContext(cpctx.AddIPToContext(t.Context(), "203.0.113.7"), "a-guest") + _, err := useCase.Execute(ctx, click_usecase.In{TileID: 42, CountryID: "fr"}) + + require.ErrorIs(t, err, clicks.ErrBanned) + assert.False(t, inner.ran) +} + +func TestAnyoneElseClicks(t *testing.T) { + inner := &fakeClick{} + useCase := ban_click.New(inner, bans{"2001:db8::/64": until}, prometheus.NewRegistry()) + + out, err := useCase.Execute(cpctx.AddIPToContext(t.Context(), "203.0.113.7"), click_usecase.In{TileID: 42, CountryID: "fr"}) + + require.NoError(t, err) + assert.True(t, inner.ran) + assert.Equal(t, clicks.Taken, out.Outcome) +} diff --git a/apps/backend/internal/planet/internal/clicks/usecases/click_usecase/usecase.go b/apps/backend/internal/planet/internal/clicks/usecases/click_usecase/usecase.go index 77e3a26ec..bf96cb9d5 100644 --- a/apps/backend/internal/planet/internal/clicks/usecases/click_usecase/usecase.go +++ b/apps/backend/internal/planet/internal/clicks/usecases/click_usecase/usecase.go @@ -32,7 +32,6 @@ type Out struct { Limited bool - // Never on the wire: a dropped click answers the zero Out, so it would expose a ban. Outcome clicks.Outcome } diff --git a/apps/backend/internal/planet/internal/planetv1controller/ban_test.go b/apps/backend/internal/planet/internal/planetv1controller/ban_test.go new file mode 100644 index 000000000..feaef65de --- /dev/null +++ b/apps/backend/internal/planet/internal/planetv1controller/ban_test.go @@ -0,0 +1,51 @@ +package planetv1controller + +import ( + "context" + "net/http" + "testing" + "time" + + "connectrpc.com/connect" + "github.com/prometheus/client_golang/prometheus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + planetv1 "github.com/raphoester/clickplanet.lol-backend/generated/proto/planet/v1" + "github.com/raphoester/clickplanet.lol-backend/internal/antibot" + "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/clicks/usecases/click_usecase/ban_click" + "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/clicks/usecases/click_usecase/throttle_click" +) + +type stubBans map[string]time.Time + +func (b stubBans) EnforcedSentence(_ context.Context, scope, _ string) (antibot.Sentence, bool) { + until, ok := b[scope] + return antibot.Sentence{Until: until}, ok +} + +func TestABannedCallerIsToldUntilWhenAndSpendsNoToken(t *testing.T) { + until := time.Date(2026, 10, 21, 21, 40, 0, 0, time.UTC) + limiter := &fakeLimiter{allow: true} + server := clickServerWith(t, + ban_click.New(throttle_click.New(stubService{}, limiter, onePrice, buckets), stubBans{"1.2.3.4": until}, prometheus.NewRegistry()), + nil, connect.WithInterceptors(errorNet())) + + _, err := clickAs(t, server, "1.2.3.4") + + var connectErr *connect.Error + require.ErrorAs(t, err, &connectErr) + require.Equal(t, connect.CodePermissionDenied, connectErr.Code()) + require.Len(t, connectErr.Details(), 1) + detail, err := connectErr.Details()[0].Value() + require.NoError(t, err) + refusal, isBan := detail.(*planetv1.BanRefusal) + require.True(t, isBan, "the detail is what tells a ban from a VPN") + assert.Equal(t, until.UnixMilli(), refusal.GetBannedUntilUnixMs()) + + require.Equal(t, http.StatusForbidden, clickStatus(t, server, "1.2.3.4")) + require.Empty(t, limiter.keys, "the banned caller never reached the bucket") + + _, err = clickAs(t, server, "5.6.7.8") + require.NoError(t, err) +} diff --git a/apps/backend/internal/planet/internal/planetv1controller/click_handler/errors.go b/apps/backend/internal/planet/internal/planetv1controller/click_handler/errors.go index a3b2b2c0b..bfc280fa2 100644 --- a/apps/backend/internal/planet/internal/planetv1controller/click_handler/errors.go +++ b/apps/backend/internal/planet/internal/planetv1controller/click_handler/errors.go @@ -7,6 +7,7 @@ import ( "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/clicks" "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/clicks/usecases/click_usecase" "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/planetv1controller/clickbudget" + "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/planetv1controller/planetban" ) var callerErrors = []error{ @@ -20,6 +21,10 @@ func toConnect(err error, out click_usecase.Out) error { return throttled(err, out) } + if refused, banned := planetban.Refusal(err); banned { + return refused + } + for _, callerError := range callerErrors { if errors.Is(err, callerError) { return connect.NewError(connect.CodeInvalidArgument, err) diff --git a/apps/backend/internal/planet/internal/planetv1controller/drop_bomb_handler/handler.go b/apps/backend/internal/planet/internal/planetv1controller/drop_bomb_handler/handler.go index 8f0a68ca1..064cf76b6 100644 --- a/apps/backend/internal/planet/internal/planetv1controller/drop_bomb_handler/handler.go +++ b/apps/backend/internal/planet/internal/planetv1controller/drop_bomb_handler/handler.go @@ -8,6 +8,7 @@ import ( planetv1 "github.com/raphoester/clickplanet.lol-backend/generated/proto/planet/v1" "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/bonuses/usecases/drop_bomb_usecase" "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/clicks" + "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/planetv1controller/planetban" ) type UseCase interface { @@ -32,6 +33,10 @@ func (h DropBombHandler) DropBomb( CountryID: req.Msg.GetCountryId(), }) + if refused, banned := planetban.Refusal(err); banned { + return nil, refused + } + switch { case err == nil: return connect.NewResponse(&planetv1.DropBombResponse{}), nil diff --git a/apps/backend/internal/planet/internal/planetv1controller/place_shield_handler/handler.go b/apps/backend/internal/planet/internal/planetv1controller/place_shield_handler/handler.go index 10ef90796..09ef97826 100644 --- a/apps/backend/internal/planet/internal/planetv1controller/place_shield_handler/handler.go +++ b/apps/backend/internal/planet/internal/planetv1controller/place_shield_handler/handler.go @@ -11,6 +11,7 @@ import ( "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/bonuses/usecases/place_shield_usecase" "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/clicks" "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/planetv1controller/chargesheld" + "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/planetv1controller/planetban" ) type UseCase interface { @@ -34,6 +35,10 @@ func (h PlaceShieldHandler) PlaceShield( CountryID: req.Msg.GetCountryId(), }) + if refused, banned := planetban.Refusal(err); banned { + return nil, refused + } + switch { case err == nil: return connect.NewResponse(&planetv1.PlaceShieldResponse{Charges: chargesheld.Encode(held)}), nil diff --git a/apps/backend/internal/planet/internal/planetv1controller/planetban/refusal.go b/apps/backend/internal/planet/internal/planetv1controller/planetban/refusal.go new file mode 100644 index 000000000..53961dd16 --- /dev/null +++ b/apps/backend/internal/planet/internal/planetv1controller/planetban/refusal.go @@ -0,0 +1,23 @@ +package planetban + +import ( + "errors" + + "connectrpc.com/connect" + + planetv1 "github.com/raphoester/clickplanet.lol-backend/generated/proto/planet/v1" + "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/clicks" +) + +func Refusal(err error) (*connect.Error, bool) { + var refusal clicks.BanRefusal + if !errors.As(err, &refusal) { + return nil, false + } + + refused := connect.NewError(connect.CodePermissionDenied, clicks.ErrBanned) + if detail, detailErr := connect.NewErrorDetail(&planetv1.BanRefusal{BannedUntilUnixMs: refusal.Until().UnixMilli()}); detailErr == nil { + refused.AddDetail(detail) + } + return refused, true +} diff --git a/apps/backend/internal/planet/internal/planetv1controller/planetban/refusal_test.go b/apps/backend/internal/planet/internal/planetv1controller/planetban/refusal_test.go new file mode 100644 index 000000000..7e7092be9 --- /dev/null +++ b/apps/backend/internal/planet/internal/planetv1controller/planetban/refusal_test.go @@ -0,0 +1,36 @@ +package planetban_test + +import ( + "fmt" + "testing" + "time" + + "connectrpc.com/connect" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + planetv1 "github.com/raphoester/clickplanet.lol-backend/generated/proto/planet/v1" + "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/clicks" + "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/planetv1controller/planetban" +) + +func TestABannedCallerIsDeniedAndToldUntilWhen(t *testing.T) { + until := time.Date(2026, 10, 21, 21, 40, 0, 0, time.UTC) + + refused, ok := planetban.Refusal(fmt.Errorf("wrapped: %w", clicks.NewBanRefusal(until))) + require.True(t, ok) + + assert.Equal(t, connect.CodePermissionDenied, refused.Code()) + assert.NotContains(t, refused.Message(), "2026", "the time travels in the detail") + require.Len(t, refused.Details(), 1) + detail, err := refused.Details()[0].Value() + require.NoError(t, err) + ban, isBan := detail.(*planetv1.BanRefusal) + require.True(t, isBan) + assert.Equal(t, until.UnixMilli(), ban.GetBannedUntilUnixMs()) +} + +func TestAnyOtherErrorIsNoBanRefusal(t *testing.T) { + _, ok := planetban.Refusal(clicks.ErrThrottled) + assert.False(t, ok) +} diff --git a/apps/backend/internal/planet/module.go b/apps/backend/internal/planet/module.go index 06b195663..b8e9f9dd2 100644 --- a/apps/backend/internal/planet/module.go +++ b/apps/backend/internal/planet/module.go @@ -38,6 +38,7 @@ import ( "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/clicks/usecases/click_usecase" "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/clicks/usecases/click_usecase/antibot_attempt_click" "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/clicks/usecases/click_usecase/antibot_click" + "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/clicks/usecases/click_usecase/ban_click" "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/clicks/usecases/click_usecase/bonus_click" "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/clicks/usecases/click_usecase/enclose_click" "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/clicks/usecases/click_usecase/enclose_click/prom_enclose" @@ -235,11 +236,13 @@ func NewModule(config Config) cpbootstrap.Module { clickUseCase = bonus_click.New(clickUseCase, registry) - // Outside the shadow ban, or a banned caller would stop seeing 429s and know. clickUseCase = throttle_click.New(clickUseCase, limiter, pricer, buckets) clickUseCase = antibot_attempt_click.New(clickUseCase, guard, geography, clock) + // Outside the throttle: a refused click must not spend a token, or the client shows a 429. + clickUseCase = ban_click.New(clickUseCase, guard, props.Metrics) + adminBatch := config.TilesStorage.SubscriberBuffer / 4 if adminBatch <= 0 { adminBatch = 256 @@ -335,14 +338,14 @@ func NewModule(config Config) cpbootstrap.Module { }, }) - dropped := prom_drop_bomb.New( - publishing_drop_bomb.New( - drop_bomb_usecase.New(charges, geography, writer, countries, bombRules), - borders, props.Events, clock), + dropBomb := prom_drop_bomb.New( + antibot_drop_bomb.New( + publishing_drop_bomb.New( + drop_bomb_usecase.New(charges, geography, writer, countries, bombRules), + borders, props.Events, clock), + guard), props.Metrics) - dropBomb := antibot_drop_bomb.New(dropped, guard) - rules := bonuses.Rules{ BlastRadius: bombRules.Radius, EnclosureMaxTiles: charges.EnclosureMaxTiles(), diff --git a/apps/backoffice/src/gen/grpc/planet/v1/planet_pb.ts b/apps/backoffice/src/gen/grpc/planet/v1/planet_pb.ts index 93e98920d..17a2f73e2 100644 --- a/apps/backoffice/src/gen/grpc/planet/v1/planet_pb.ts +++ b/apps/backoffice/src/gen/grpc/planet/v1/planet_pb.ts @@ -259,6 +259,45 @@ export class ClickResponse extends Message { } } +/** + * The detail of a PermissionDenied that Click, DropBomb and PlaceShield answer a banned caller. + * + * @generated from message planet.v1.BanRefusal + */ +export class BanRefusal extends Message { + /** + * @generated from field: int64 banned_until_unix_ms = 1; + */ + bannedUntilUnixMs = protoInt64.zero; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "planet.v1.BanRefusal"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "banned_until_unix_ms", kind: "scalar", T: 3 /* ScalarType.INT64 */ }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): BanRefusal { + return new BanRefusal().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): BanRefusal { + return new BanRefusal().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): BanRefusal { + return new BanRefusal().fromJsonString(jsonString, options); + } + + static equals(a: BanRefusal | PlainMessage | undefined, b: BanRefusal | PlainMessage | undefined): boolean { + return proto3.util.equals(BanRefusal, a, b); + } +} + /** * @generated from message planet.v1.GetBudgetRequest */ diff --git a/apps/frontend/CLAUDE.md b/apps/frontend/CLAUDE.md index 69350771d..404e13484 100644 --- a/apps/frontend/CLAUDE.md +++ b/apps/frontend/CLAUDE.md @@ -67,6 +67,8 @@ landmass starts locked to France. `fakeBackend.shareClicks("guests")` (or `"network"`) reads the bucket as shared, and `fakeBackend.shareClicks()` as the player's own again. `fakeBackend.closeShapes(false)` makes every enclose click close nothing, for the bubble that says so, and `closeShapes(true)` puts it back. +`fakeBackend.ban(90)` refuses this player's clicks, bombs and shields for 90 +minutes, as a ban does, and `fakeBackend.ban(0)` lifts it. `localStorage.removeItem("clickplanet-bonus-guide")` makes the bonuses new again. `fakeSupport.refuse("rate-limited")` (or `"rejected"`, `"no-session"`, `"reported-already"`, `"failed"`) refuses what Settings and a player's card @@ -332,23 +334,30 @@ with no wait, and **a click that timed out is not sent again**, since it may have landed and costs a click and maybe a charge. It is rolled back, and the echo repaints it if it did land. -The backend refuses a click in three ways, and `clickTile` translates all of -them into errors declared beside the interfaces — the first two in `backend.ts`, -the third in `session.ts` — so `globe.ts` recognises them without knowing what a +The backend refuses a click in four ways, and `clickTile` translates all of +them into errors declared beside the interfaces — the first three in `backend.ts`, +the last in `session.ts` — so `globe.ts` recognises them without knowing what a Connect code is: - `resource_exhausted` → `RateLimitedError`. The per-IP token bucket is spent. -- `permission_denied` → `VPNBlockedError`. The address is in the backend's VPN - and proxy blocklist. +- `permission_denied` with a `BanRefusal` detail → `BannedError`, with `until`, + when the ban ends. The account or its network is banned (see the backend's + CLAUDE.md, "A ban is told"). +- `permission_denied` with no detail → `VPNBlockedError`. The address is in the + backend's VPN and proxy blocklist. - `unauthenticated` → `SessionUnavailableError`, **and only after a retry** — see [Sessions](#sessions). They are separate classes rather than one with a field because each one says something different: ease off for a second (the click meter shakes — no dialog), -turn the VPN off, or reload and unblock the challenge. Everything else is a transport fault and still reaches the -console. +wait for the ban to end, turn the VPN off, or reload and unblock the challenge. +Everything else is a transport fault and still reaches the console. -`FakeBackend` reproduces all three, so every refusal is reachable in dev: it +**A bomb and a shield are refused for a ban the same way** (`asBonusError` reads +the detail), and the bomb or the shield stays in hand, since the server spent +nothing. + +`FakeBackend` reproduces all four, so every refusal is reachable in dev: it enforces the same bucket as production's `rateLimiter` (one click every 5s, 60 in hand), and takes `vpnBlocked` and `sessionUnavailable` options that refuse every click (there is no address and no widget there to judge). Its own simulated traffic bypasses all of them, standing @@ -2285,11 +2294,11 @@ and are shared; how thick a line is drawn between them is this app's. A refused click is taken back off the map. A throttled one bumps `refusals` in `useGlobe`, and `ClickBudgetMeter` shakes and flashes red once per bump — a dialog here was annoying, since a player hits the wall mid-burst and the meter - already says why. The other two raise a flag that `Viewer` renders as - `VPNBlockedModal` or `SessionUnavailableModal`. The globe reports every refused - click, so those flags are booleans and not a queue — a burst is one thing to - say, once. - `reportClickFailure` in `globe.ts` is the four-way branch that picks which, + already says why. The other three raise a flag that `Viewer` renders as + `BannedModal`, `VPNBlockedModal` or `SessionUnavailableModal`. The globe reports + every refused click, so those flags hold one value and are not a queue — a burst + is one thing to say, once. + `reportClickFailure` in `globe.ts` is the five-way branch that picks which, split out of the click handler because it is the one piece of that handler worth testing: sending a refusal to the wrong dialog leaves a working page giving the wrong advice. @@ -2298,6 +2307,12 @@ and are shared; how thick a line is drawn between them is this app's. refusal does not clear on its own — the player has to change network — so the next click raises it again. + **`BannedModal` says until when** (`describeEnd`, the chat mute's words: the + time under 20 hours, then the day, and the year when it is not this one), and + links the terms' "Play fair" section in a new tab (`GAME_RULES`, + `/terms#play-fair`). It gives no reason, on purpose: the server sends none. + Bombs and shields refused for a ban raise it too (`reportClaimFailure`). + 6. `ClickBudgetMeter` shows what is left of the bucket, in the dock at the bottom (see [The screen](#the-screen-four-zones)). It warns before the wall, and shakes when a click hits it. **Its shape is read off the server's policy**: one @@ -2469,8 +2484,8 @@ the account's. `ClaimBonusResponse.charges` replaces them on a claim, and `PlaceShieldResponse.charges` on a shield placed. Otherwise it follows its own calls: an accepted click **sent with spread on** takes a spread click off, this player's own `tilesEnclosed` takes an enclosure off, and a drop -takes the bomb off at once and gives it back only if the call never reached the -server; a shield is taken off the same way. The click answer says nothing about charges, on purpose (see the backend's +takes the bomb off at once and gives it back unless the server says it holds +none (a ban refusal gives it back too); a shield is taken off the same way. The click answer says nothing about charges, on purpose (see the backend's CLAUDE.md). A charge spent in another tab stays on screen until the next read. It reaches the globe through `BonusHandlers.onCharges`, and `useGlobe` hands it to the inventory. @@ -2534,13 +2549,12 @@ charge is spent. The same line is in the slot's `aria-describedby`. player's own tiles when a shield stopped the click. - **Shield on, a full tile**: "Full" on the slot and a line saying so. - **Enclose on, a click that took a tile and closed no shape**: "Shape is not - closed or is too big (25 tiles max)". **This is guessed on the client, on - purpose**: `Click` answers nothing about bonuses so a shadow-banned caller - cannot tell its clicks are dropped (see the backend's CLAUDE.md), and the - server cannot tell an open shape from one too big either. The globe waits - `ENCLOSURE_WAIT_MS` (1.5s) after the click is accepted for this player's own - `tilesEnclosed` closed at that tile, and says it when none came. A banned - player learns nothing new: it sees no enclosure either way. + closed or is too big (25 tiles max)". **This is guessed on the client**: + `Click` answers nothing about bonuses (it once hid a shadow ban; see the + backend's CLAUDE.md), and the server cannot tell an open shape from one too big + either. The globe waits `ENCLOSURE_WAIT_MS` (1.5s) after the click is accepted + for this player's own `tilesEnclosed` closed at that tile, and says it when none + came. **What a player has learned is kept in the browser** (`clickplanet-bonus-guide`, `domain/bonusGuide.ts`, `useBonusGuide`): how many times each kind was switched @@ -3456,7 +3470,9 @@ to the plain pages, which write `%DISCORD_INVITE%`, `%TIKTOK_PROFILE%` and `links.test.ts` fails on one of them pasted into a page. **`terms.html` is the terms of service**, linked beside it and built -the same way, at `/terms`. Discord asks for its URL to allow OAuth sign-in. The +the same way, at `/terms`. Discord asks for its URL to allow OAuth sign-in. +**`BannedModal` links its "Play fair" section** by its `id="play-fair"`, which +`BannedModal.test.tsx` pins: keep the id if the heading changes. The privacy policy says what Google and Discord send us; a new provider, or a new field kept from one, changes that page and its date. diff --git a/apps/frontend/src/app/chat/ChatComposer.tsx b/apps/frontend/src/app/chat/ChatComposer.tsx index 500877aa5..3dc1a904e 100644 --- a/apps/frontend/src/app/chat/ChatComposer.tsx +++ b/apps/frontend/src/app/chat/ChatComposer.tsx @@ -1,6 +1,7 @@ import {FormEvent, KeyboardEvent, useEffect, useLayoutEffect, useRef, useState} from "react"; import {ChatMessage, countRunes, MAX_TEXT_LENGTH} from "../../backends/chat.ts"; import {quoteOf} from "../../domain/chatLog.ts"; +import {describeEnd} from "../../domain/endTime.ts"; import {CloseIcon} from "../components/icons.tsx"; import {useEscape} from "../components/useDialog.ts"; import {quoteStyle} from "./authorStyle.ts"; @@ -19,10 +20,6 @@ export type ChatComposerProps = { const COUNTER_SHOWS_FROM = MAX_TEXT_LENGTH - 40 -const clock = new Intl.DateTimeFormat(undefined, {hour: "2-digit", minute: "2-digit"}) -const calendar = new Intl.DateTimeFormat(undefined, {month: "short", day: "numeric", hour: "2-digit", minute: "2-digit"}) -const CLOCK_READS_FOR_MS = 20 * 3600_000 - const FAILURES: Record = { 'rate-limited': "You're sending messages too fast. Give it a few seconds.", 'blocked': "This connection is not allowed to post.", @@ -127,6 +124,5 @@ function Replying({to, onCancel}: {to: ChatMessage, onCancel: () => void}) { function noticeOf(failure: ChatSendFailure, mutedUntil: number | undefined): string { if (failure !== 'muted' || mutedUntil === undefined) return FAILURES[failure] - const until = mutedUntil - Date.now() < CLOCK_READS_FOR_MS ? clock.format(mutedUntil) : calendar.format(mutedUntil) - return `You are muted until ${until}.` + return `You are muted until ${describeEnd(mutedUntil, Date.now())}.` } diff --git a/apps/frontend/src/app/components/BannedModal.css b/apps/frontend/src/app/components/BannedModal.css new file mode 100644 index 000000000..b61210440 --- /dev/null +++ b/apps/frontend/src/app/components/BannedModal.css @@ -0,0 +1,3 @@ +.banned-text { + padding: var(--space-1) 0 var(--space-4) 0; +} diff --git a/apps/frontend/src/app/components/BannedModal.test.tsx b/apps/frontend/src/app/components/BannedModal.test.tsx new file mode 100644 index 000000000..c76f85624 --- /dev/null +++ b/apps/frontend/src/app/components/BannedModal.test.tsx @@ -0,0 +1,44 @@ +// @vitest-environment jsdom +import {afterEach, describe, expect, it, vi} from "vitest" +import {cleanup, render, screen} from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import BannedModal, {GAME_RULES} from "./BannedModal.tsx" +import {describeEnd} from "../../domain/endTime.ts" +import terms from "../../../terms.html?raw" + +afterEach(cleanup) + +describe("BannedModal", () => { + it("says until when the clicks are paused", () => { + const until = Date.now() + 3600_000 + render() + + expect(screen.getByText(`Clicks from your account or network are paused until ${describeEnd(until, Date.now())}.`)).not.toBeNull() + }) + + it("links to the play fair rules in a new tab", () => { + render() + + const link = screen.getByRole("link", {name: "Read the game rules"}) + expect(link.getAttribute("href")).toBe(GAME_RULES) + expect(link.getAttribute("target")).toBe("_blank") + }) + + it("points at a section the terms page has", () => { + const [page, anchor] = GAME_RULES.split("#") + + expect(page).toBe("/terms") + expect(terms).toContain(`id="${anchor}"`) + }) + + it("closes on the footer button, the × and Escape", async () => { + const onClose = vi.fn() + const user = userEvent.setup() + render() + + await user.click(screen.getByRole("button", {name: "Got it"})) + await user.click(screen.getByRole("button", {name: "Close"})) + await user.keyboard("{Escape}") + expect(onClose).toHaveBeenCalledTimes(3) + }) +}) diff --git a/apps/frontend/src/app/components/BannedModal.tsx b/apps/frontend/src/app/components/BannedModal.tsx new file mode 100644 index 000000000..74725b670 --- /dev/null +++ b/apps/frontend/src/app/components/BannedModal.tsx @@ -0,0 +1,22 @@ +import Modal from "./Modal.tsx"; +import {describeEnd} from "../../domain/endTime.ts"; +import "./BannedModal.css" + +export const GAME_RULES = "/terms#play-fair" + +export type BannedModalProps = { + until: number + onClose: () => void +} + +export default function BannedModal(props: BannedModalProps) { + return Got it} + onClose={props.onClose}> +
+

Clicks from your account or network are paused until {describeEnd(props.until, Date.now())}.

+

Read the game rules

+
+
+} diff --git a/apps/frontend/src/app/viewer/Viewer.tsx b/apps/frontend/src/app/viewer/Viewer.tsx index f2cdba69a..14e3d891b 100644 --- a/apps/frontend/src/app/viewer/Viewer.tsx +++ b/apps/frontend/src/app/viewer/Viewer.tsx @@ -35,6 +35,7 @@ import {useBoardGuide} from "../useBoardGuide.ts"; import {isFirstWin} from "../../domain/bonusGuide.ts"; import SessionUnavailableModal from "../components/SessionUnavailableModal.tsx"; import VPNBlockedModal from "../components/VPNBlockedModal.tsx"; +import BannedModal from "../components/BannedModal.tsx"; import SharePlace from "../share/SharePlace.tsx"; import SharePreview from "../share/SharePreview.tsx"; import {useSharePicture} from "../share/useSharePicture.ts"; @@ -160,6 +161,8 @@ export default function Viewer(props: ViewerProps) { refusals, vpnBlocked, dismissVPNBlocked, + bannedUntil, + dismissBanned, sessionUnavailable, dismissSessionUnavailable, award, @@ -430,6 +433,7 @@ export default function Viewer(props: ViewerProps) { {vpnBlocked && } + {bannedUntil !== undefined && } {sessionUnavailable && } diff --git a/apps/frontend/src/app/viewer/globe.test.ts b/apps/frontend/src/app/viewer/globe.test.ts index 9c6b2d02e..657f443c7 100644 --- a/apps/frontend/src/app/viewer/globe.test.ts +++ b/apps/frontend/src/app/viewer/globe.test.ts @@ -1,6 +1,6 @@ import {describe, expect, it, vi} from "vitest" -import {drawsFrame, reportClickFailure, spinStep} from "./globe.ts" -import {RateLimitedError, VPNBlockedError} from "../../backends/backend.ts" +import {drawsFrame, reportClaimFailure, reportClickFailure, spinStep} from "./globe.ts" +import {BannedError, BonusLostError, RateLimitedError, VPNBlockedError} from "../../backends/backend.ts" import {SessionUnavailableError} from "../../backends/session.ts" import {OwnerChange, TileOwnership} from "../../domain/tileOwnership.ts" import {LeaderboardEntry, rankCountries} from "../../domain/leaderboard.ts" @@ -9,7 +9,7 @@ import {TileField} from "./tileField.ts" import {regions} from "./atlas.ts" function handlers() { - return {onRateLimited: vi.fn(), onVPNBlocked: vi.fn(), onSessionUnavailable: vi.fn()} + return {onRateLimited: vi.fn(), onVPNBlocked: vi.fn(), onBanned: vi.fn(), onSessionUnavailable: vi.fn()} } describe("reportClickFailure", () => { @@ -33,6 +33,18 @@ describe("reportClickFailure", () => { expect(h.onSessionUnavailable).not.toHaveBeenCalled() }) + it("sends a ban to its own dialog with its end, and not to the VPN one", () => { + const h = handlers() + const until = Date.now() + 3600_000 + + expect(reportClickFailure(new BannedError(until), h)).toBe(true) + + expect(h.onBanned).toHaveBeenCalledWith(until) + expect(h.onVPNBlocked).not.toHaveBeenCalled() + expect(h.onRateLimited).not.toHaveBeenCalled() + expect(h.onSessionUnavailable).not.toHaveBeenCalled() + }) + it("sends a session that could not be obtained to its own dialog, and nowhere else", () => { const h = handlers() @@ -57,6 +69,27 @@ describe("reportClickFailure", () => { }) }) +describe("reportClaimFailure", () => { + it("sends a bomb or a shield refused for a ban to the ban dialog", () => { + const h = {onSessionUnavailable: vi.fn(), onBanned: vi.fn()} + const until = Date.now() + 3600_000 + + reportClaimFailure(new BannedError(until), h) + + expect(h.onBanned).toHaveBeenCalledWith(until) + expect(h.onSessionUnavailable).not.toHaveBeenCalled() + }) + + it("says nothing of a bonus that was lost", () => { + const h = {onSessionUnavailable: vi.fn(), onBanned: vi.fn()} + + reportClaimFailure(new BonusLostError(), h) + + expect(h.onBanned).not.toHaveBeenCalled() + expect(h.onSessionUnavailable).not.toHaveBeenCalled() + }) +}) + describe("a refused click, from the paint to the rollback", () => { const size = 32 diff --git a/apps/frontend/src/app/viewer/globe.ts b/apps/frontend/src/app/viewer/globe.ts index 572994737..729189fd5 100644 --- a/apps/frontend/src/app/viewer/globe.ts +++ b/apps/frontend/src/app/viewer/globe.ts @@ -17,6 +17,7 @@ import {regions} from "./atlas.ts"; import {Country} from "../../domain/countries.ts"; import {MapView, Rendering} from "../../domain/displaySettings.ts"; import { + BannedError, BombDrop, Bomber, GlobePoint, @@ -155,6 +156,7 @@ export type GlobeOptions = { onLoadProgress: (share: number) => void onRateLimited: () => void onVPNBlocked: () => void + onBanned: (until: number) => void onSessionUnavailable: () => void onBonusTaken: (taken: BonusCatch) => void onBonusWon: (reward: BonusReward) => void @@ -207,6 +209,7 @@ export async function createGlobe(options: GlobeOptions): Promise { onLoadProgress, onRateLimited, onVPNBlocked, + onBanned, onSessionUnavailable, onBonusTaken, onBonusWon, @@ -488,7 +491,7 @@ export async function createGlobe(options: GlobeOptions): Promise { bomber.dropBomb({x: point.x, y: point.y, z: point.z}, country.code).catch((e) => { if (lifetime.signal.aborted) return ownDropAt = undefined - reportClaimFailure(e, {onSessionUnavailable}) + reportClaimFailure(e, {onSessionUnavailable, onBanned}) }) } @@ -543,7 +546,7 @@ export async function createGlobe(options: GlobeOptions): Promise { shielder.placeShield(tile, country.code).catch((e) => { if (lifetime.signal.aborted || e instanceof ShieldRefusedError) return - reportClaimFailure(e, {onSessionUnavailable}) + reportClaimFailure(e, {onSessionUnavailable, onBanned}) }) } @@ -668,7 +671,7 @@ export async function createGlobe(options: GlobeOptions): Promise { .then(takeReward) .catch((e) => { if (lifetime.signal.aborted) return - reportClaimFailure(e, {onSessionUnavailable}) + reportClaimFailure(e, {onSessionUnavailable, onBanned}) }) return } @@ -734,7 +737,7 @@ export async function createGlobe(options: GlobeOptions): Promise { }, (e) => { if (lifetime.signal.aborted) return applyChanges(ownership.rollback(claim)) - if (reportClickFailure(e, {onRateLimited, onVPNBlocked, onSessionUnavailable})) playSound("refused") + if (reportClickFailure(e, {onRateLimited, onVPNBlocked, onBanned, onSessionUnavailable})) playSound("refused") }) }, listenerOptions); @@ -1052,10 +1055,11 @@ function prefersReducedMotion(): boolean { export function reportClaimFailure( error: unknown, - handlers: {onSessionUnavailable: () => void}, + handlers: {onSessionUnavailable: () => void, onBanned: (until: number) => void}, ) { if (error instanceof BonusLostError) return if (error instanceof SessionUnavailableError) handlers.onSessionUnavailable() + else if (error instanceof BannedError) handlers.onBanned(error.until) else console.error(error) } @@ -1064,11 +1068,13 @@ export function reportClickFailure( handlers: { onRateLimited: () => void, onVPNBlocked: () => void, + onBanned: (until: number) => void, onSessionUnavailable: () => void, }, ): boolean { if (error instanceof RateLimitedError) handlers.onRateLimited() else if (error instanceof VPNBlockedError) handlers.onVPNBlocked() + else if (error instanceof BannedError) handlers.onBanned(error.until) else if (error instanceof SessionUnavailableError) handlers.onSessionUnavailable() else { console.error(error) diff --git a/apps/frontend/src/app/viewer/useGlobe.ts b/apps/frontend/src/app/viewer/useGlobe.ts index f157512c5..1914f2c85 100644 --- a/apps/frontend/src/app/viewer/useGlobe.ts +++ b/apps/frontend/src/app/viewer/useGlobe.ts @@ -52,6 +52,7 @@ export function useGlobe(options: UseGlobeOptions) { const notify = useCallback((what: BonusNotice) => setNotice((was) => ({notice: what, seq: (was?.seq ?? 0) + 1})), []) const [vpnBlocked, setVPNBlocked] = useState(false) + const [bannedUntil, setBannedUntil] = useState() const [sessionUnavailable, setSessionUnavailable] = useState(false) @@ -113,6 +114,7 @@ export function useGlobe(options: UseGlobeOptions) { }, onRateLimited: () => setRefusals(n => n + 1), onVPNBlocked: () => setVPNBlocked(true), + onBanned: setBannedUntil, onSessionUnavailable: () => setSessionUnavailable(true), onBonusWon: takeBonus, onBonusTaken: recordCatch, @@ -197,6 +199,8 @@ export function useGlobe(options: UseGlobeOptions) { refusals, vpnBlocked, dismissVPNBlocked: () => setVPNBlocked(false), + bannedUntil, + dismissBanned: () => setBannedUntil(undefined), sessionUnavailable, dismissSessionUnavailable: () => setSessionUnavailable(false), award, diff --git a/apps/frontend/src/backends/backend.ts b/apps/frontend/src/backends/backend.ts index ca50bc0d7..c5a9143fe 100644 --- a/apps/frontend/src/backends/backend.ts +++ b/apps/frontend/src/backends/backend.ts @@ -172,3 +172,13 @@ export class VPNBlockedError extends Error { this.name = "VPNBlockedError" } } + +export class BannedError extends Error { + readonly until: number + + constructor(until: number, options?: {cause?: unknown}) { + super("the account or its network is banned", options) + this.name = "BannedError" + this.until = until + } +} diff --git a/apps/frontend/src/backends/fakeBackend.ts b/apps/frontend/src/backends/fakeBackend.ts index 179bf806a..606283385 100644 --- a/apps/frontend/src/backends/fakeBackend.ts +++ b/apps/frontend/src/backends/fakeBackend.ts @@ -1,5 +1,6 @@ import { BankFullError, + BannedError, BombDrop, Bomber, BonusHandlers, @@ -128,6 +129,7 @@ export class FakeBackend implements TileClicker, OwnershipsGetter, UpdatesListen private pace = 1 private sharedWith: SharedBy | undefined private shapesClose = true + private bannedUntil: number | undefined private readonly vpnBlocked: boolean private readonly sessionUnavailable: boolean @@ -214,6 +216,7 @@ export class FakeBackend implements TileClicker, OwnershipsGetter, UpdatesListen public async clickTile(tileId: number, countryId: string, switches: Switches = ALL_OFF) { if (switches.spread && switches.enclose) throw new Error("spread and enclose switched on together") if (this.sessionUnavailable) throw new SessionUnavailableError() + this.refuseBanned() if (this.vpnBlocked) throw new VPNBlockedError() const allowed = this.allow(countryId) @@ -329,6 +332,14 @@ export class FakeBackend implements TileClicker, OwnershipsGetter, UpdatesListen this.budgetCallbacks.forEach(callback => callback(budget)) } + public ban(minutes = 60): void { + this.bannedUntil = minutes > 0 ? Date.now() + minutes * 60_000 : undefined + } + + private refuseBanned() { + if (this.bannedUntil !== undefined && Date.now() < this.bannedUntil) throw new BannedError(this.bannedUntil) + } + public closeShapes(close: boolean): void { this.shapesClose = close } @@ -438,6 +449,7 @@ export class FakeBackend implements TileClicker, OwnershipsGetter, UpdatesListen public async placeShield(tileId: number, countryId: string): Promise { if (this.sessionUnavailable) throw new SessionUnavailableError() + this.refuseBanned() if (this.charges.shields === 0) throw new BonusLostError() if (!this.botShield(tileId, countryId)) throw new ShieldRefusedError() @@ -559,6 +571,7 @@ export class FakeBackend implements TileClicker, OwnershipsGetter, UpdatesListen public async dropBomb(target: GlobePoint, countryId: string): Promise { if (this.sessionUnavailable) throw new SessionUnavailableError() + this.refuseBanned() if (!this.charges.bomb) throw new BonusLostError() this.hold({...this.charges, bomb: false}) diff --git a/apps/frontend/src/backends/planetBackend.test.ts b/apps/frontend/src/backends/planetBackend.test.ts index 3ff1b0e2f..ed8f81691 100644 --- a/apps/frontend/src/backends/planetBackend.test.ts +++ b/apps/frontend/src/backends/planetBackend.test.ts @@ -15,6 +15,7 @@ import { } from "./planetBackend.ts" import {Code, ConnectError} from "@connectrpc/connect" import { + BanRefusal, BombDropped, BonusKind, BonusOffered, @@ -34,7 +35,7 @@ import { TilesSpread, TileUpdate, } from "../gen/grpc/planet/v1/planet_pb.ts" -import {BankFullError, BonusLostError, ShieldRefusedError, RateLimitedError, VPNBlockedError} from "./backend.ts" +import {BankFullError, BannedError, BonusLostError, ShieldRefusedError, RateLimitedError, VPNBlockedError} from "./backend.ts" import {SESSION_HEADER, type SessionProvider, SessionUnavailableError} from "./session.ts" import type {ClickBudget} from "./clickBudget.ts" import type {BonusRules, Charges} from "../domain/bonus.ts" @@ -375,6 +376,21 @@ describe("PlanetBackend.clickTile", () => { backend.close() }) + it("reports a ban as a BannedError with its end, never as the VPN", async () => { + const until = 1_700_003_600_000 + const refused = new ConnectError("the caller is banned", Code.PermissionDenied, undefined, + [new BanRefusal({bannedUntilUnixMs: BigInt(until)})]) + const click = vi.fn().mockRejectedValue(refused) + const backend = backendWith(click) + + const error = await backend.clickTile(1, "fr").catch((e: unknown) => e) + + expect(error).toBeInstanceOf(BannedError) + expect(error).toMatchObject({until, cause: refused}) + expect(click).toHaveBeenCalledTimes(1) + backend.close() + }) + it("keeps the VPN refusal as the cause too", async () => { const refused = new ConnectError("clicks from VPN addresses are refused", Code.PermissionDenied) const backend = backendWith(vi.fn().mockRejectedValue(refused)) @@ -997,6 +1013,15 @@ describe("PlanetBackend bombs", () => { backend.close() }) + it("reports a bomb refused for a ban as a BannedError", async () => { + const dropBomb = vi.fn().mockRejectedValue(new ConnectError("the caller is banned", Code.PermissionDenied, undefined, + [new BanRefusal({bannedUntilUnixMs: BigInt(1_700_003_600_000)})])) + const backend = new PlanetBackend(clientWith({dropBomb}), 1_000) + + await expect(backend.dropBomb({x: 1, y: 0, z: 0}, "fr")).rejects.toBeInstanceOf(BannedError) + backend.close() + }) + it("hands over the tile updates that came before a blast, before the blast", async () => { const events = [ tileUpdateEvent({tileId: 7, countryId: "jp"}), @@ -1237,6 +1262,15 @@ describe("asBonusError", () => { .toBeInstanceOf(SessionUnavailableError) }) + it("reads a ban on a bomb or a shield, and leaves any other denial alone", () => { + const banned = new ConnectError("the caller is banned", Code.PermissionDenied, undefined, + [new BanRefusal({bannedUntilUnixMs: BigInt(1_700_003_600_000)})]) + expect(asBonusError(banned)).toMatchObject({until: 1_700_003_600_000}) + + const denied = new ConnectError("no", Code.PermissionDenied) + expect(asBonusError(denied)).toBe(denied) + }) + it("leaves anything else alone", () => { const boom = new Error("boom") expect(asBonusError(boom)).toBe(boom) diff --git a/apps/frontend/src/backends/planetBackend.ts b/apps/frontend/src/backends/planetBackend.ts index 0537b2715..f18c4e36d 100644 --- a/apps/frontend/src/backends/planetBackend.ts +++ b/apps/frontend/src/backends/planetBackend.ts @@ -1,5 +1,6 @@ import { BankFullError, + BannedError, BombDrop, Bomber, BonusCatch, @@ -27,6 +28,7 @@ import { import {ALL_OFF, BonusReward, BonusRules, Charges, NO_CHARGES, Switches} from "../domain/bonus.ts"; import {QuizOffer, QuizOutcome, QuizQuestion} from "../domain/quiz.ts"; import { + BanRefusal, BonusKind, ChargesHeld, ClickBudget as ClickBudgetMessage, @@ -756,11 +758,17 @@ export function asBonusError(e: unknown): unknown { if (e instanceof ConnectError) { if (e.code === Code.NotFound || e.code === Code.Unimplemented) return new BonusLostError({cause: e}) if (e.code === Code.Unauthenticated) return new SessionUnavailableError({cause: e}) + if (e.code === Code.PermissionDenied) return bannedOf(e) ?? e } return e } +function bannedOf(e: ConnectError): BannedError | undefined { + const refusal = e.findDetails(BanRefusal)[0] + return refusal && new BannedError(Number(refusal.bannedUntilUnixMs), {cause: e}) +} + export function asShieldError(e: unknown): unknown { if (e instanceof ConnectError && e.code === Code.FailedPrecondition) return new ShieldRefusedError({cause: e}) @@ -799,7 +807,7 @@ export function asClickError(e: unknown): unknown { if (e instanceof ConnectError) { if (e.code === Code.ResourceExhausted) return new RateLimitedError({cause: e}) - if (e.code === Code.PermissionDenied) return new VPNBlockedError({cause: e}) + if (e.code === Code.PermissionDenied) return bannedOf(e) ?? new VPNBlockedError({cause: e}) if (e.code === Code.Unauthenticated) return new SessionUnavailableError({cause: e}) } diff --git a/apps/frontend/src/clip/main.ts b/apps/frontend/src/clip/main.ts index 14c13fd1c..757427006 100644 --- a/apps/frontend/src/clip/main.ts +++ b/apps/frontend/src/clip/main.ts @@ -433,6 +433,7 @@ async function choose(): Promise { onLoadProgress: () => {}, onRateLimited: () => {}, onVPNBlocked: () => {}, + onBanned: () => {}, onSessionUnavailable: () => {}, onBonusTaken: () => {}, onBonusWon: () => {}, diff --git a/apps/frontend/src/domain/endTime.test.ts b/apps/frontend/src/domain/endTime.test.ts new file mode 100644 index 000000000..1f4eaa042 --- /dev/null +++ b/apps/frontend/src/domain/endTime.test.ts @@ -0,0 +1,25 @@ +import {describe, expect, it} from "vitest"; +import {describeEnd} from "./endTime.ts"; + +const now = new Date(2026, 9, 20, 18, 0).getTime() + +describe("describeEnd", () => { + it("says the time alone under 20 hours", () => { + const at = new Date(2026, 9, 21, 9, 40).getTime() + + expect(describeEnd(at, now)).toBe(new Intl.DateTimeFormat(undefined, {hour: "2-digit", minute: "2-digit"}).format(at)) + }) + + it("says the day from 20 hours", () => { + const at = new Date(2026, 9, 27, 18, 0).getTime() + + expect(describeEnd(at, now)).toContain(new Intl.DateTimeFormat(undefined, {day: "numeric"}).format(at)) + expect(describeEnd(at, now)).not.toContain("2026") + }) + + it("says the year when it is not this one", () => { + const at = new Date(2029, 9, 20, 18, 0).getTime() + + expect(describeEnd(at, now)).toContain("2029") + }) +}) diff --git a/apps/frontend/src/domain/endTime.ts b/apps/frontend/src/domain/endTime.ts new file mode 100644 index 000000000..1a9ddf27d --- /dev/null +++ b/apps/frontend/src/domain/endTime.ts @@ -0,0 +1,11 @@ +const clock = new Intl.DateTimeFormat(undefined, {hour: "2-digit", minute: "2-digit"}) +const calendar = new Intl.DateTimeFormat(undefined, {month: "short", day: "numeric", hour: "2-digit", minute: "2-digit"}) +const yearly = new Intl.DateTimeFormat(undefined, {year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit"}) + +const CLOCK_READS_FOR_MS = 20 * 3600_000 + +export function describeEnd(at: number, now: number): string { + if (at - now < CLOCK_READS_FOR_MS) return clock.format(at) + if (new Date(at).getFullYear() !== new Date(now).getFullYear()) return yearly.format(at) + return calendar.format(at) +} diff --git a/apps/frontend/src/gen/grpc/planet/v1/planet_pb.ts b/apps/frontend/src/gen/grpc/planet/v1/planet_pb.ts index d8a3e9282..2106a75ed 100644 --- a/apps/frontend/src/gen/grpc/planet/v1/planet_pb.ts +++ b/apps/frontend/src/gen/grpc/planet/v1/planet_pb.ts @@ -259,6 +259,45 @@ export class ClickResponse extends Message { } } +/** + * The detail of a PermissionDenied that Click, DropBomb and PlaceShield answer a banned caller. + * + * @generated from message planet.v1.BanRefusal + */ +export class BanRefusal extends Message { + /** + * @generated from field: int64 banned_until_unix_ms = 1; + */ + bannedUntilUnixMs = protoInt64.zero; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "planet.v1.BanRefusal"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "banned_until_unix_ms", kind: "scalar", T: 3 /* ScalarType.INT64 */ }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): BanRefusal { + return new BanRefusal().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): BanRefusal { + return new BanRefusal().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): BanRefusal { + return new BanRefusal().fromJsonString(jsonString, options); + } + + static equals(a: BanRefusal | PlainMessage | undefined, b: BanRefusal | PlainMessage | undefined): boolean { + return proto3.util.equals(BanRefusal, a, b); + } +} + /** * @generated from message planet.v1.GetBudgetRequest */ diff --git a/apps/frontend/terms.html b/apps/frontend/terms.html index 1a39fb3a7..b4a08d05c 100644 --- a/apps/frontend/terms.html +++ b/apps/frontend/terms.html @@ -56,6 +56,7 @@ font-size: var(--display-l); margin: 36px 0 8px; color: var(--gold); + scroll-margin-top: 16px; } a { @@ -116,7 +117,7 @@ ← Back to the planet

Terms of service

-

Last updated: 17 October 2026

+

Last updated: 20 October 2026

ClickPlanet is a free online game run by Рафаел Остер ЕООД (Raphael Oester EOOD), a company registered in Bulgaria, EIK 206853249. Its registered address is @@ -134,16 +135,17 @@

Accounts

Discord, your ClickPlanet account is linked to that account. You are responsible for what is done with your account. The privacy policy says what data this uses.

-

Play fair

+

Play fair

You must not:

  • use bots, scripts, macros, auto-clickers or any other automation;
  • +
  • play several accounts, or switch between accounts, to get more clicks;
  • use several connections, addresses, VPNs or proxies to get more clicks;
  • read or call the game's API other than through the website;
  • attack, overload or try to break the game, its servers or its anti-cheat checks;
  • exploit a bug instead of reporting it.
-

We detect cheating automatically and by hand. We can, without notice, ignore your clicks, undo the tiles +

We detect cheating automatically and by hand. We can, without notice, pause your clicks, undo the tiles you took, ban your address or account, or block your network. If you think we made a mistake, email privacy@clickplanet.lol.

diff --git a/deploy/vps/README.md b/deploy/vps/README.md index 7359d79ed..aec43ecb0 100644 --- a/deploy/vps/README.md +++ b/deploy/vps/README.md @@ -470,13 +470,13 @@ Each returns `certain` or `suspect`. **`certain` bans on its own; `suspect` is a reading that would ban real players if it were trusted alone**, and counts only alongside a second watchdog. `jury.minSuspects` (2) is how many it takes. -A flagged caller's clicks are answered `OK` and dropped. That is deliberately not -a refusal — a 403 names the check that tripped, and a silent no-op names nothing, -so working around it is guesswork instead of a diff. It is not permanent: the -caller reads the map back over the same stream and will notice eventually. +A banned caller's clicks, bombs and shields are refused with a 403 that says when +the ban ends, and the game shows it with a link to the rules. No reason goes out. +Until 2026-10-20 they were answered `OK` and dropped (a shadow ban), and most of +the people banned by mistake took it for a bug. -`backend.yaml` now runs with `shadowBan.enforce: true`. It spent 2026-09-11 in -observe mode (`enforce: false`, which judges and logs without dropping anything) +`backend.yaml` now runs with `bans.enforce: true`. It spent 2026-09-11 in +observe mode (`enforce: false`, which judges and logs without refusing anything) and the bounds below were set from what that pass recorded — see "What the measuring pass found". **Anything you widen here, widen back in observe mode first.** @@ -606,16 +606,16 @@ interceptor, so this number partly measures the limiter. That is tolerable at `certainClicks: 900` are the ones that ban alone here, and the claim they rest on is the absence of any pause past `maxGap`, not the spread. -Then `shadowBan.enforce: true` and redeploy. +Then `bans.enforce: true` and redeploy. -`shadowban_flagged` is how many callers are inside a ban and **counts while +`banned_callers` is how many callers are inside a ban and **counts while `enforce` is false too** — a non-zero gauge in observe mode means the rules are -biting, not that anything was dropped. `shadowbanned_clicks` is the one that +biting, not that anything was refused. `banned_clicks` is the one that stays at 0 until you enforce. -`shadowban_flags` counts flags rather than callers, **labelled by watchdog**, so -the two read together: `shadowban_flags{watchdog="sequencer"} 40` against -`shadowban_flagged 2` is two callers flagged twenty times each, which is a very +`ban_flags` counts flags rather than callers, **labelled by watchdog**, so +the two read together: `ban_flags{watchdog="sequencer"} 40` against +`banned_callers 2` is two callers flagged twenty times each, which is a very different picture from forty callers caught once. The labels also tell you which watchdog is earning its keep before you enforce. All of them are readable with the `wget` line above. @@ -638,7 +638,7 @@ docker compose exec backend wget -qO- localhost:8080/metrics | grep antibot_opin **Levels are cumulative**: `suspect` includes every `certain`, so `suspect − certain` is the near misses. `antibot_opinions_total{watchdog="sequencer",level="suspect"} 30` -with `shadowban_flags` still at 0 is thirty suspicions nobody corroborated — look +with `ban_flags` still at 0 is thirty suspicions nobody corroborated — look at what the other watchdogs were reading on the same callers before loosening `jury.minSuspects`. The poller keeps both. @@ -686,8 +686,10 @@ if `/var/log/journal` does not exist the journal is memory-only and this buys nothing. **`cp-metrics-poller` scrapes `/metrics` every `POLL_INTERVAL` seconds** and -appends the shadowban and click series to the `metrics_history` volume, one -file per UTC day, pruned after `POLL_RETENTION_DAYS`: +appends the ban and click series to the `metrics_history` volume, one +file per UTC day, pruned after `POLL_RETENTION_DAYS`. Before 2026-10-20 the ban +series were named `shadowban_flagged`, `shadowban_flags` and `shadowbanned_clicks` +(now `banned_callers`, `ban_flags` and `banned_clicks`): ```bash docker compose exec -T metrics-poller ls /history @@ -1210,8 +1212,8 @@ docker compose exec backend wget -qO- --header 'Content-Type: application/json' Ban the scope when the player is on a guest account: a guest drops its account with a new cookie. -`"enforced":false` in the answer means `antiBot.shadowBan.enforce` is off: the -ban is kept but drops nothing. To lift a ban, see [Unban a player](#unban-a-player). +`"enforced":false` in the answer means `antiBot.bans.enforce` is off: the +ban is kept but refuses nothing. To lift a ban, see [Unban a player](#unban-a-player). Then revert, dry run first: @@ -1249,7 +1251,7 @@ docker exec cp-backend wget -qO- --header 'Content-Type: application/json' --pos - **Fix the rule first.** The unban does not change what the watchdogs hold. If a rule still reads the player `certain`, the jury bans it again on its next click, at the earliest 5 minutes after the last ban - (`antiBot.shadowBan.reflagInterval`). + (`antiBot.bans.reflagInterval`). [`InspectPlayer`](#see-how-close-the-antibot-is-to-one-player) shows which rule it was. Change the rule and deploy, then unban. - **The ban's offence is forgotten**: the next ban takes the same step of the diff --git a/deploy/vps/backend.yaml b/deploy/vps/backend.yaml index 58bea0ec5..f86c6b4a3 100644 --- a/deploy/vps/backend.yaml +++ b/deploy/vps/backend.yaml @@ -64,7 +64,7 @@ antiBot: pool: maxOpenConns: 2 - shadowBan: + bans: enforce: true banDurations: [24h, 168h, 26280h] reflagInterval: 5m diff --git a/deploy/vps/docker-compose.yaml b/deploy/vps/docker-compose.yaml index 613266e95..e976da0f7 100644 --- a/deploy/vps/docker-compose.yaml +++ b/deploy/vps/docker-compose.yaml @@ -94,7 +94,7 @@ services: if wget -qO- --timeout=10 http://backend:8080/metrics > /tmp/scrape; then # Keep || true: grep exits 1 on no match, and set -e would kill the loop. { echo "# $$stamp" - grep -E '^(click_reaction_seconds|click_retake_share|click_busy_hours|shadowban|antibot_opinions|blocked_clicks|click_session_checks)' /tmp/scrape || true + grep -E '^(click_reaction_seconds|click_retake_share|click_busy_hours|banned_|ban_flags|antibot_opinions|blocked_clicks|click_session_checks)' /tmp/scrape || true echo } >> "$$file" else diff --git a/proto/planet/v1/planet.proto b/proto/planet/v1/planet.proto index c827562a6..7afefd198 100644 --- a/proto/planet/v1/planet.proto +++ b/proto/planet/v1/planet.proto @@ -86,6 +86,11 @@ message ClickResponse { ClickBudget budget = 1; } +// The detail of a PermissionDenied that Click, DropBomb and PlaceShield answer a banned caller. +message BanRefusal { + int64 banned_until_unix_ms = 1; +} + message GetBudgetRequest { string country_id = 1; }