From 5907cbb7ae66924cbb2725bbe8a9d98b6cb040e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Oester?= Date: Sat, 17 Oct 2026 20:04:06 +0200 Subject: [PATCH 1/3] feat(planet): an account changes its flag once an hour Every click and every shield names a flag, and nothing held a player to one: a player could paint for one country and defend another, click to click. The server now keeps the flag each account plays for. The first flag starts no wait and the first change is free; after a change, a click or a shield for another flag within the hour is refused with FailedPrecondition and a planet.v1.Allegiance detail. The check sits outside the throttle and the shadow ban, so a refused click spends no token and a banned caller is refused like any other. ClickService/GetAllegiance tells the client the flag held, and the picker says when another country may be played. Co-Authored-By: Claude Opus 5.5 --- apps/backend/.golangci.yaml | 1 + apps/backend/CLAUDE.md | 48 +- apps/backend/e2e/allegiance_test.go | 51 ++ apps/backend/e2e/fortify_test.go | 5 +- apps/backend/e2e/player_test.go | 8 +- .../generated/proto/planet/v1/planet.pb.go | 628 +++++++++++------- .../v1/planetv1connect/planet.connect.go | 31 + apps/backend/internal/planet/config.go | 3 + .../planet/internal/allegiances/allegiance.go | 48 ++ .../internal/allegiances/allegiance_test.go | 63 ++ .../planet/internal/allegiances/book.go | 42 ++ .../planet/internal/allegiances/book_test.go | 79 +++ .../planet/internal/allegiances/change.go | 37 ++ .../persistence_testing.go | 66 ++ .../inmemory_allegiance_storage/storage.go | 153 +++++ .../storage_test.go | 86 +++ .../postgres_allegiance_store/store.go | 75 +++ .../postgres_allegiance_store/store_test.go | 78 +++ .../usecases/place_shield_usecase/usecase.go | 24 +- .../place_shield_usecase/usecase_test.go | 31 +- .../click_usecase/allegiance_click/usecase.go | 29 + .../allegiance_click/usecase_test.go | 69 ++ .../20261017130000_allegiances.down.sql | 1 + .../20261017130000_allegiances.up.sql | 5 + .../allegiancemessage/allegiance.go | 34 + .../click_handler/errors.go | 5 + .../click_handler/handler_test.go | 19 + .../planetv1controller/click_service.go | 2 + .../allegiance_query/query_memory.go | 34 + .../allegiance_query/query_memory_test.go | 44 ++ .../get_allegiance_handler/handler.go | 30 + .../place_shield_handler/handler.go | 5 + .../planetv1controller/session_interceptor.go | 1 + apps/backend/internal/planet/module.go | 23 +- apps/frontend/CLAUDE.md | 25 +- apps/frontend/src/app/App.tsx | 1 + apps/frontend/src/app/CountryPicker.css | 5 + apps/frontend/src/app/CountryPicker.tsx | 12 + apps/frontend/src/app/Menu.test.tsx | 23 + apps/frontend/src/app/Menu.tsx | 6 +- apps/frontend/src/app/viewer/Viewer.tsx | 13 +- apps/frontend/src/app/viewer/globe.test.ts | 15 +- apps/frontend/src/app/viewer/globe.ts | 5 +- apps/frontend/src/app/viewer/useAllegiance.ts | 11 + apps/frontend/src/backends/backend.ts | 12 + apps/frontend/src/backends/fakeBackend.ts | 37 +- .../src/backends/planetBackend.test.ts | 93 ++- apps/frontend/src/backends/planetBackend.ts | 76 ++- apps/frontend/src/domain/allegiance.test.ts | 16 + apps/frontend/src/domain/allegiance.ts | 10 + .../src/gen/grpc/planet/v1/planet_connect.ts | 13 +- .../src/gen/grpc/planet/v1/planet_pb.ts | 117 ++++ apps/frontend/src/main.tsx | 2 + proto/planet/v1/planet.proto | 18 + 54 files changed, 2095 insertions(+), 273 deletions(-) create mode 100644 apps/backend/e2e/allegiance_test.go create mode 100644 apps/backend/internal/planet/internal/allegiances/allegiance.go create mode 100644 apps/backend/internal/planet/internal/allegiances/allegiance_test.go create mode 100644 apps/backend/internal/planet/internal/allegiances/book.go create mode 100644 apps/backend/internal/planet/internal/allegiances/book_test.go create mode 100644 apps/backend/internal/planet/internal/allegiances/change.go create mode 100644 apps/backend/internal/planet/internal/allegiances/inmemory_allegiance_storage/persistence_testing.go create mode 100644 apps/backend/internal/planet/internal/allegiances/inmemory_allegiance_storage/storage.go create mode 100644 apps/backend/internal/planet/internal/allegiances/inmemory_allegiance_storage/storage_test.go create mode 100644 apps/backend/internal/planet/internal/allegiances/postgres_allegiance_store/store.go create mode 100644 apps/backend/internal/planet/internal/allegiances/postgres_allegiance_store/store_test.go create mode 100644 apps/backend/internal/planet/internal/clicks/usecases/click_usecase/allegiance_click/usecase.go create mode 100644 apps/backend/internal/planet/internal/clicks/usecases/click_usecase/allegiance_click/usecase_test.go create mode 100644 apps/backend/internal/planet/internal/migrations/20261017130000_allegiances.down.sql create mode 100644 apps/backend/internal/planet/internal/migrations/20261017130000_allegiances.up.sql create mode 100644 apps/backend/internal/planet/internal/planetv1controller/allegiancemessage/allegiance.go create mode 100644 apps/backend/internal/planet/internal/planetv1controller/get_allegiance_handler/allegiance_query/query_memory.go create mode 100644 apps/backend/internal/planet/internal/planetv1controller/get_allegiance_handler/allegiance_query/query_memory_test.go create mode 100644 apps/backend/internal/planet/internal/planetv1controller/get_allegiance_handler/handler.go create mode 100644 apps/frontend/src/app/CountryPicker.css create mode 100644 apps/frontend/src/app/viewer/useAllegiance.ts create mode 100644 apps/frontend/src/domain/allegiance.test.ts create mode 100644 apps/frontend/src/domain/allegiance.ts diff --git a/apps/backend/.golangci.yaml b/apps/backend/.golangci.yaml index b9f0756e6..3ced78d35 100644 --- a/apps/backend/.golangci.yaml +++ b/apps/backend/.golangci.yaml @@ -82,6 +82,7 @@ linters: - cpbootstrap.Run( - .Click( + - .Pledge( - .GetMap( - .SendMessage( - .ListenForEvents( diff --git a/apps/backend/CLAUDE.md b/apps/backend/CLAUDE.md index d2436c959..99fa3cd1b 100644 --- a/apps/backend/CLAUDE.md +++ b/apps/backend/CLAUDE.md @@ -352,6 +352,9 @@ internal/planet/internal/ inmemory_charge_storage/ postgres_charge_store/ usecases/ + allegiances/ the flag each account plays for, and when it last changed it + inmemory_allegiance_storage/ + postgres_allegiance_store/ planetv1controller/ the edge: maps the wire to the use cases, nothing else get_takes_by_country_handler/takes_query/ InternalService's one read, straight from ledger_events subscribers/ the edge for events: auth.v1.AccountDeleted → anonymize_takes_usecase @@ -422,7 +425,7 @@ because it serves every concept over one Connect service. It only maps. | `bonuses/usecases/get_charges_usecase` | what the caller holds | `Charges` | | `bonuses/usecases/use_refill_usecase` | fills the caller's bank with its refill | `Refills`, `Bank`, `Pricer` | | `bonuses/usecases/grant_charges_usecase` | the operator gives an account charges | `Charger` | -| `bonuses/usecases/place_shield_usecase` | spends a shield on a tile of the caller's flag | `Shields`, `Tiles`, `CountryChecker` | +| `bonuses/usecases/place_shield_usecase` | spends a shield on a tile of the caller's flag | `Shields`, `Tiles`, `CountryChecker`, `Allegiances` | | `clicks/usecases/relay_fortifications_usecase` | follows the tile map and publishes `planet.v1.Fortified` for each fortify; a `Runner` | `Changes`, `Publisher` | **The interfaces in that last column are declared by the package that calls @@ -434,7 +437,7 @@ these ports at once, which is why `module.go` hands it over several times. **`click_usecase` is the only one with an interface of its own (`IUseCase`)**, because the click chain decorates it — `prom_click`, `throttle_click`, -`antibot_click`, `antibot_attempt_click`, and the bonus decorators `spread_click`, `enclose_click` and +`antibot_click`, `antibot_attempt_click`, `allegiance_click`, and the bonus decorators `spread_click`, `enclose_click` and `bonus_click`. The counting is a wrapper rather than a line inside the rule, so a process that does not want it leaves it out and the rule does not change. @@ -714,6 +717,7 @@ POST /planet.v1.ClickService/Click [X-Session-Token: ] 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) + → allegiance_click (another flag within the hour of a change is refused, and spends 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) → prom_click (counts) @@ -855,6 +859,43 @@ defence against taking back the last tile for a free refill, and it costs no tim happened. It subscribes again a second after the map cuts it off. The chat announces the ones of `fortifyNewsTiles` (50) tiles or more. +### Allegiance: one change of flag an hour + +**An account plays for one flag, and may change it once an hour.** Every click and +every shield names a flag, and nothing else ties a player to one: before this, a +player could paint for one country and defend another click to click. + +- **The rule is `Allegiance.Pledged(flag, at)`**, pure and tested in the + `allegiances` root. The first flag an account acts for is kept with no wait, so a + player who clicked for its time zone's flag can still pick its own. The next + change is free too, and starts the wait: another flag within `changeEvery` (1h) of + it is `allegiances.Refusal` (`ErrChangedTooSoon`), with the flag held and until + when. The same flag again changes nothing. +- **`allegiances.Book.Pledge(payer, flag)`** applies it at the clock's time, under + the storage's lock, so two tabs clicking two flags at once cannot both change it. + A caller with no account, and a flag that is no country, are held to nothing: the + click use case refuses the second. +- **It is checked outside the throttle and outside the shadow ban** + (`allegiance_click`, between `antibot_attempt_click` and `throttle_click`): a click + refused for its flag spends no token, and a banned caller is refused exactly as + any other, or switching flags would tell it its clicks are dropped. + `place_shield_usecase` pledges before it spends the shield. A bomb, a claim, a + quiz and a refill name a flag only for what they announce, and are not held. +- **The refusal is `FailedPrecondition` with a `planet.v1.Allegiance` detail** + (`allegiancemessage.Refusal`): the flag held and `changeable_at_unix_ms`. The + client snaps back to that flag. +- **`ClickService/GetAllegiance`** answers the caller's (`allegiance_query`, from + memory), as `Identified`: unset for an account that never acted for a flag, and + `changeable_at_unix_ms` 0 for a flag never changed. Not `NO_SIDE_EFFECTS`. +- **Kept like the charges**: `inmemory_allegiance_storage` holds every account's + in memory, and flushes the ones that changed to `planet.allegiances` + (`account`, `flag`, `changed_at`, NULL for a first flag) every + `allegianceStorage.flushInterval`. **A row is never deleted**, not even with its + account: without the row the next flag is a first flag again, which would hand + out a free change. The row names nobody once the account is gone. +- **Guests are held per account**, like the bank: a private tab is a new guest and + a new first flag. + ### Chat (`internal/chat/`) Chat is a separate bounded context, not a feature of the tile game: it shares the process, the transport and the country list, and has its own proto package, domain, storage and edge. Nothing under `internal/chat/` imports `internal/planet/`, and the reverse holds too — and since each module's interior sits behind its own `internal/`, neither now can. @@ -3114,6 +3155,8 @@ Both chains order them the same way: error mapping outermost, then the blocklist - **Size.** 1M takes are 83 MB of table and 31 MB of indexes (the primary key and the account). Production made ~116,000 takes in 72h at the start of October 2026, ~40,000 a day: ~15M rows and ~1.7 GB a year. Each take is written twice, once by the `COPY` and once when its scope is blanked, and autovacuum reuses the space. No partitioning yet. - **One pool for every runner.** `cppg.CloseAfter(db, logger, tilesStorage, takings, charges, deletions)` runs them together and closes the pool after the last flushes, and after the subscriber drained its buffer. +**The allegiances do too**, through `inmemory_allegiance_storage.Persistence` and `allegiances/postgres_allegiance_store`, on the same pool: one row per account in `planet.allegiances`. See [Allegiance](#allegiance-one-change-of-flag-an-hour). + **The charges follow it too**, through `inmemory_charge_storage.Persistence` and `bonuses/postgres_charge_store`, on the same pool: one row per account in `planet.charges`, written every `chargeStorage.flushInterval`. See [Charges](#charges-refill-bomb-enclose-spread-shields). The antibot's evidence is in postgres too, in the `antibot` schema, the same way, and its bans are read and written there on every call — see [What survives a restart](#what-survives-a-restart). The player module's profiles and stats are in postgres too, in the `player` schema, but with no memory copy: each call reads or writes the table — see [Player](#player-internalplayer). The seasons module's standings are kept the same way, in the `seasons` schema — see [Seasons](#seasons-internalseasons). So are the support module's notes, in the `support` schema — see [Support](#support-internalsupport). @@ -3656,6 +3699,7 @@ There is no struct-tag validation and therefore no validator dependency — a ho - `bonus.enclose.held`, `bonus.enclose.maxPerBox`, `bonus.enclose.maxTiles` — the most enclosures held (3), the most one box adds (3; it draws 1 to that), and the most tiles one shape may take (25) - `bonus.shield.held`, `bonus.shield.minPerBox`, `bonus.shield.maxPerBox`, `bonus.shield.perTile` — the most shields held (30), the fewest and the most one box adds (5 and 20; it draws evenly between them), and the most one tile holds (10) - `chargeStorage.flushInterval` — how often the charges that changed are written to postgres (default 1s); also flushed on shutdown +- `allegianceStorage.flushInterval` — how often the flags 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 diff --git a/apps/backend/e2e/allegiance_test.go b/apps/backend/e2e/allegiance_test.go new file mode 100644 index 000000000..586425bd6 --- /dev/null +++ b/apps/backend/e2e/allegiance_test.go @@ -0,0 +1,51 @@ +package e2e_test + +import ( + "net/http" + "testing" + + "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/generated/proto/planet/v1/planetv1connect" +) + +func (p *gamer) allegiance() *planetv1.Allegiance { + p.t.Helper() + + req := connect.NewRequest(&planetv1.GetAllegianceRequest{}) + p.send(req.Header()) + res, err := planetv1connect.NewClickServiceClient(http.DefaultClient, p.stack.baseURL).GetAllegiance(p.t.Context(), req) + require.NoError(p.t, err) + + return res.Msg.GetAllegiance() +} + +func TestAPlayerChangesItsFlagOnceAndIsThenHeldToIt(t *testing.T) { + game := startGame(t) + ada := game.newPlayer(t) + + assert.Nil(t, ada.allegiance(), "a player that never clicked plays for nobody") + + ada.click(1, "fr") + assert.Zero(t, ada.allegiance().GetChangeableAtUnixMs(), "the first flag may change at once") + + ada.click(2, "de") + changed := ada.allegiance() + assert.Equal(t, "de", changed.GetCountryId()) + assert.NotZero(t, changed.GetChangeableAtUnixMs()) + + err := ada.tryClick(3, "fr") + require.Equal(t, connect.CodeFailedPrecondition, connect.CodeOf(err)) + var refused *connect.Error + require.ErrorAs(t, err, &refused) + require.Len(t, refused.Details(), 1) + detail, err := refused.Details()[0].Value() + require.NoError(t, err) + assert.Equal(t, "de", detail.(*planetv1.Allegiance).GetCountryId()) + + require.NoError(t, ada.tryClick(3, "de"), "the flag held still plays") + require.NoError(t, game.newPlayer(t).tryClick(4, "fr"), "another player is not held to it") +} diff --git a/apps/backend/e2e/fortify_test.go b/apps/backend/e2e/fortify_test.go index 153ae2176..c7816c7cb 100644 --- a/apps/backend/e2e/fortify_test.go +++ b/apps/backend/e2e/fortify_test.go @@ -92,8 +92,9 @@ func TestTakingAWholeLandmassFortifiesItForEveryScreen(t *testing.T) { assert.Equal(t, uint32(1), shielded[tile], "tile %d holds one shield", tile) } - ada.click(members[landmass][0], "es") - ada.click(members[landmass][0], "es") + eve := game.newPlayer(t) + eve.click(members[landmass][0], "es") + eve.click(members[landmass][0], "es") ada.click(members[landmass][0], "pt") select { case event := <-fortified: diff --git a/apps/backend/e2e/player_test.go b/apps/backend/e2e/player_test.go index a4f6c562b..1d5327be8 100644 --- a/apps/backend/e2e/player_test.go +++ b/apps/backend/e2e/player_test.go @@ -378,11 +378,9 @@ func TestAPlayerKeepsItsCardWhenAnotherTakesTheNameItLeft(t *testing.T) { func TestAPlayerReadsEveryCountryItPlaysAgainstAndAnybodyTheTopThree(t *testing.T) { game := startGame(t) - ada := game.newPlayer(t) - ada.click(1, "fr") - ada.click(2, "it") - ada.click(3, "es") - ada.click(4, "pt") + for tile, flag := range []string{"fr", "it", "es", "pt"} { + game.newPlayer(t).click(uint32(tile)+1, flag) + } bob := game.newPlayer(t) bob.link("google-bob") _, err := bob.setName("Bob_B") diff --git a/apps/backend/generated/proto/planet/v1/planet.pb.go b/apps/backend/generated/proto/planet/v1/planet.pb.go index 0cb1baff4..0ebec176b 100644 --- a/apps/backend/generated/proto/planet/v1/planet.pb.go +++ b/apps/backend/generated/proto/planet/v1/planet.pb.go @@ -131,6 +131,141 @@ func (BonusKind) EnumDescriptor() ([]byte, []int) { return file_planet_v1_planet_proto_rawDescGZIP(), []int{1} } +type GetAllegianceRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetAllegianceRequest) Reset() { + *x = GetAllegianceRequest{} + mi := &file_planet_v1_planet_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetAllegianceRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetAllegianceRequest) ProtoMessage() {} + +func (x *GetAllegianceRequest) ProtoReflect() protoreflect.Message { + mi := &file_planet_v1_planet_proto_msgTypes[0] + 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 GetAllegianceRequest.ProtoReflect.Descriptor instead. +func (*GetAllegianceRequest) Descriptor() ([]byte, []int) { + return file_planet_v1_planet_proto_rawDescGZIP(), []int{0} +} + +type GetAllegianceResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Unset for an account that never acted for a flag. + Allegiance *Allegiance `protobuf:"bytes,1,opt,name=allegiance,proto3" json:"allegiance,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetAllegianceResponse) Reset() { + *x = GetAllegianceResponse{} + mi := &file_planet_v1_planet_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetAllegianceResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetAllegianceResponse) ProtoMessage() {} + +func (x *GetAllegianceResponse) ProtoReflect() protoreflect.Message { + mi := &file_planet_v1_planet_proto_msgTypes[1] + 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 GetAllegianceResponse.ProtoReflect.Descriptor instead. +func (*GetAllegianceResponse) Descriptor() ([]byte, []int) { + return file_planet_v1_planet_proto_rawDescGZIP(), []int{1} +} + +func (x *GetAllegianceResponse) GetAllegiance() *Allegiance { + if x != nil { + return x.Allegiance + } + return nil +} + +// Also the detail of a click or a shield refused for another flag. +type Allegiance struct { + state protoimpl.MessageState `protogen:"open.v1"` + CountryId string `protobuf:"bytes,1,opt,name=country_id,json=countryId,proto3" json:"country_id,omitempty"` + // When another flag may be played: 0 for a flag never changed. + ChangeableAtUnixMs int64 `protobuf:"varint,2,opt,name=changeable_at_unix_ms,json=changeableAtUnixMs,proto3" json:"changeable_at_unix_ms,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Allegiance) Reset() { + *x = Allegiance{} + mi := &file_planet_v1_planet_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Allegiance) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Allegiance) ProtoMessage() {} + +func (x *Allegiance) ProtoReflect() protoreflect.Message { + mi := &file_planet_v1_planet_proto_msgTypes[2] + 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 Allegiance.ProtoReflect.Descriptor instead. +func (*Allegiance) Descriptor() ([]byte, []int) { + return file_planet_v1_planet_proto_rawDescGZIP(), []int{2} +} + +func (x *Allegiance) GetCountryId() string { + if x != nil { + return x.CountryId + } + return "" +} + +func (x *Allegiance) GetChangeableAtUnixMs() int64 { + if x != nil { + return x.ChangeableAtUnixMs + } + return 0 +} + type ClickBudget struct { state protoimpl.MessageState `protogen:"open.v1"` Tokens float64 `protobuf:"fixed64,1,opt,name=tokens,proto3" json:"tokens,omitempty"` @@ -148,7 +283,7 @@ type ClickBudget struct { func (x *ClickBudget) Reset() { *x = ClickBudget{} - mi := &file_planet_v1_planet_proto_msgTypes[0] + mi := &file_planet_v1_planet_proto_msgTypes[3] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -160,7 +295,7 @@ func (x *ClickBudget) String() string { func (*ClickBudget) ProtoMessage() {} func (x *ClickBudget) ProtoReflect() protoreflect.Message { - mi := &file_planet_v1_planet_proto_msgTypes[0] + mi := &file_planet_v1_planet_proto_msgTypes[3] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -173,7 +308,7 @@ func (x *ClickBudget) ProtoReflect() protoreflect.Message { // Deprecated: Use ClickBudget.ProtoReflect.Descriptor instead. func (*ClickBudget) Descriptor() ([]byte, []int) { - return file_planet_v1_planet_proto_rawDescGZIP(), []int{0} + return file_planet_v1_planet_proto_rawDescGZIP(), []int{3} } func (x *ClickBudget) GetTokens() float64 { @@ -251,7 +386,7 @@ type ClickRequest struct { func (x *ClickRequest) Reset() { *x = ClickRequest{} - mi := &file_planet_v1_planet_proto_msgTypes[1] + mi := &file_planet_v1_planet_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -263,7 +398,7 @@ func (x *ClickRequest) String() string { func (*ClickRequest) ProtoMessage() {} func (x *ClickRequest) ProtoReflect() protoreflect.Message { - mi := &file_planet_v1_planet_proto_msgTypes[1] + mi := &file_planet_v1_planet_proto_msgTypes[4] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -276,7 +411,7 @@ func (x *ClickRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ClickRequest.ProtoReflect.Descriptor instead. func (*ClickRequest) Descriptor() ([]byte, []int) { - return file_planet_v1_planet_proto_rawDescGZIP(), []int{1} + return file_planet_v1_planet_proto_rawDescGZIP(), []int{4} } func (x *ClickRequest) GetTileId() uint32 { @@ -316,7 +451,7 @@ type ClickResponse struct { func (x *ClickResponse) Reset() { *x = ClickResponse{} - mi := &file_planet_v1_planet_proto_msgTypes[2] + mi := &file_planet_v1_planet_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -328,7 +463,7 @@ func (x *ClickResponse) String() string { func (*ClickResponse) ProtoMessage() {} func (x *ClickResponse) ProtoReflect() protoreflect.Message { - mi := &file_planet_v1_planet_proto_msgTypes[2] + mi := &file_planet_v1_planet_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -341,7 +476,7 @@ func (x *ClickResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ClickResponse.ProtoReflect.Descriptor instead. func (*ClickResponse) Descriptor() ([]byte, []int) { - return file_planet_v1_planet_proto_rawDescGZIP(), []int{2} + return file_planet_v1_planet_proto_rawDescGZIP(), []int{5} } func (x *ClickResponse) GetBudget() *ClickBudget { @@ -360,7 +495,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[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -372,7 +507,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[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -385,7 +520,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{6} } func (x *GetBudgetRequest) GetCountryId() string { @@ -404,7 +539,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[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -416,7 +551,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[7] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -429,7 +564,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{7} } func (x *GetBudgetResponse) GetBudget() *ClickBudget { @@ -447,7 +582,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[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -459,7 +594,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[8] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -472,7 +607,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{8} } type MapDensityResponse struct { @@ -484,7 +619,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[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -496,7 +631,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[9] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -509,7 +644,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{9} } func (x *MapDensityResponse) GetDensity() uint32 { @@ -529,7 +664,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[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -541,7 +676,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[10] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -554,7 +689,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{10} } func (x *GetMapRequest) GetStartTileId() uint32 { @@ -583,7 +718,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[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -595,7 +730,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[11] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -608,7 +743,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{11} } func (x *GetMapResponse) GetStartTileId() uint32 { @@ -649,7 +784,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[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -661,7 +796,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[12] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -674,7 +809,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{12} } func (x *TileShields) GetTileId() uint32 { @@ -699,7 +834,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[13] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -711,7 +846,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[13] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -724,7 +859,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{13} } type PlanetEvent struct { @@ -747,7 +882,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[14] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -759,7 +894,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[14] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -772,7 +907,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{14} } func (x *PlanetEvent) GetEvent() isPlanetEvent_Event { @@ -929,7 +1064,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[15] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -941,7 +1076,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[15] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -954,7 +1089,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{15} } // Every landmass a flag fortified last, or holds whole with nobody having fortified it. @@ -967,7 +1102,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[16] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -979,7 +1114,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[16] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -992,7 +1127,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{16} } func (x *GetFortressesResponse) GetFortresses() []*Fortress { @@ -1013,7 +1148,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[17] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1025,7 +1160,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[17] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1038,7 +1173,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{17} } func (x *Fortress) GetLandmassId() uint32 { @@ -1069,7 +1204,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[18] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1081,7 +1216,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[18] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1094,7 +1229,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{18} } func (x *LandmassFortified) GetLandmassId() uint32 { @@ -1131,7 +1266,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[19] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1143,7 +1278,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[19] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1156,7 +1291,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{19} } func (x *ChargesHeld) GetRefill() bool { @@ -1202,7 +1337,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[20] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1214,7 +1349,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[20] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1227,7 +1362,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{20} } type GetChargesResponse struct { @@ -1239,7 +1374,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[21] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1251,7 +1386,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[21] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1264,7 +1399,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{21} } func (x *GetChargesResponse) GetCharges() *ChargesHeld { @@ -1282,7 +1417,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[22] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1294,7 +1429,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[22] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1307,7 +1442,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{22} } type GetBonusRulesResponse struct { @@ -1326,7 +1461,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[23] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1338,7 +1473,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[23] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1351,7 +1486,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{23} } func (x *GetBonusRulesResponse) GetBlastRadius() float64 { @@ -1413,7 +1548,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[24] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1425,7 +1560,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[24] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1438,7 +1573,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{24} } func (x *TollStep) GetShare() float64 { @@ -1467,7 +1602,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[25] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1479,7 +1614,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[25] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1492,7 +1627,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{25} } func (x *BonusOffered) GetToken() string { @@ -1534,7 +1669,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[26] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1546,7 +1681,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[26] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1559,7 +1694,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{26} } func (x *BonusTaken) GetCountryId() string { @@ -1593,7 +1728,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[27] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1605,7 +1740,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[27] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1618,7 +1753,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{27} } func (x *ClaimBonusRequest) GetToken() string { @@ -1646,7 +1781,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[28] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1658,7 +1793,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[28] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1671,7 +1806,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{28} } func (x *ClaimBonusResponse) GetKind() BonusKind { @@ -1705,7 +1840,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[29] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1717,7 +1852,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[29] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1730,7 +1865,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{29} } func (x *QuizOffered) GetToken() string { @@ -1756,7 +1891,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[30] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1768,7 +1903,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[30] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1781,7 +1916,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{30} } func (x *OpenQuizRequest) GetToken() string { @@ -1803,7 +1938,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[31] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1815,7 +1950,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[31] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1828,7 +1963,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{31} } func (x *OpenQuizResponse) GetQuestion() string { @@ -1870,7 +2005,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[32] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1882,7 +2017,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[32] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1895,7 +2030,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{32} } func (x *AnswerQuizRequest) GetToken() string { @@ -1932,7 +2067,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[33] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1944,7 +2079,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[33] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1957,7 +2092,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{33} } func (x *AnswerQuizResponse) GetCorrect() bool { @@ -2004,7 +2139,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[34] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2016,7 +2151,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[34] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2029,7 +2164,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{34} } func (x *UseRefillRequest) GetCountryId() string { @@ -2049,7 +2184,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[35] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2061,7 +2196,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[35] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2074,7 +2209,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{35} } func (x *UseRefillResponse) GetBudget() *ClickBudget { @@ -2102,7 +2237,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[36] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2114,7 +2249,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[36] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2127,7 +2262,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{36} } func (x *GlobePoint) GetX() float64 { @@ -2161,7 +2296,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[37] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2173,7 +2308,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[37] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2186,7 +2321,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{37} } func (x *DropBombRequest) GetTarget() *GlobePoint { @@ -2211,7 +2346,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[38] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2223,7 +2358,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[38] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2236,7 +2371,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{38} } type BombDropped struct { @@ -2254,7 +2389,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[39] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2266,7 +2401,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[39] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2279,7 +2414,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{39} } func (x *BombDropped) GetTileId() uint32 { @@ -2337,7 +2472,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[40] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2349,7 +2484,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[40] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2362,7 +2497,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{40} } func (x *TilesEnclosed) GetCountryId() string { @@ -2411,7 +2546,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[41] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2423,7 +2558,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[41] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2436,7 +2571,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{41} } func (x *TilesSpread) GetCountryId() string { @@ -2470,7 +2605,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[42] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2482,7 +2617,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[42] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2495,7 +2630,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{42} } func (x *PlaceShieldRequest) GetTileId() uint32 { @@ -2521,7 +2656,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[43] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2533,7 +2668,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[43] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2546,7 +2681,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{43} } func (x *PlaceShieldResponse) GetCharges() *ChargesHeld { @@ -2564,7 +2699,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[44] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2576,7 +2711,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[44] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2589,7 +2724,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{44} } type TileUpdate struct { @@ -2607,7 +2742,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[45] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2619,7 +2754,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[45] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2632,7 +2767,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{45} } func (x *TileUpdate) GetTileId() uint32 { @@ -2674,7 +2809,17 @@ var File_planet_v1_planet_proto protoreflect.FileDescriptor const file_planet_v1_planet_proto_rawDesc = "" + "\n" + - "\x16planet/v1/planet.proto\x12\tplanet.v1\"\xd4\x02\n" + + "\x16planet/v1/planet.proto\x12\tplanet.v1\"\x16\n" + + "\x14GetAllegianceRequest\"N\n" + + "\x15GetAllegianceResponse\x125\n" + + "\n" + + "allegiance\x18\x01 \x01(\v2\x15.planet.v1.AllegianceR\n" + + "allegiance\"^\n" + + "\n" + + "Allegiance\x12\x1d\n" + + "\n" + + "country_id\x18\x01 \x01(\tR\tcountryId\x121\n" + + "\x15changeable_at_unix_ms\x18\x02 \x01(\x03R\x12changeableAtUnixMs\"\xd4\x02\n" + "\vClickBudget\x12\x16\n" + "\x06tokens\x18\x01 \x01(\x01R\x06tokens\x12\x1a\n" + "\bcapacity\x18\x02 \x01(\rR\bcapacity\x12*\n" + @@ -2874,7 +3019,7 @@ const file_planet_v1_planet_proto_rawDesc = "" + "\x18BONUS_KIND_SPREAD_CLICKS\x10\x02\x12\x13\n" + "\x0fBONUS_KIND_BOMB\x10\x03\x12\x1d\n" + "\x19BONUS_KIND_ENCLOSE_CLICKS\x10\x04\x12\x16\n" + - "\x12BONUS_KIND_SHIELDS\x10\x062\xa9\b\n" + + "\x12BONUS_KIND_SHIELDS\x10\x062\xfd\b\n" + "\fClickService\x12:\n" + "\x05Click\x12\x17.planet.v1.ClickRequest\x1a\x18.planet.v1.ClickResponse\x12F\n" + "\tGetBudget\x12\x1b.planet.v1.GetBudgetRequest\x1a\x1c.planet.v1.GetBudgetResponse\x12N\n" + @@ -2893,7 +3038,8 @@ const file_planet_v1_planet_proto_rawDesc = "" + "\n" + "AnswerQuiz\x12\x1c.planet.v1.AnswerQuizRequest\x1a\x1d.planet.v1.AnswerQuizResponse\x12L\n" + "\vPlaceShield\x12\x1d.planet.v1.PlaceShieldRequest\x1a\x1e.planet.v1.PlaceShieldResponse\x12W\n" + - "\rGetFortresses\x12\x1f.planet.v1.GetFortressesRequest\x1a .planet.v1.GetFortressesResponse\"\x03\x90\x02\x01B\xb3\x01\n" + + "\rGetFortresses\x12\x1f.planet.v1.GetFortressesRequest\x1a .planet.v1.GetFortressesResponse\"\x03\x90\x02\x01\x12R\n" + + "\rGetAllegiance\x12\x1f.planet.v1.GetAllegianceRequest\x1a .planet.v1.GetAllegianceResponseB\xb3\x01\n" + "\rcom.planet.v1B\vPlanetProtoP\x01ZPgithub.com/raphoester/clickplanet.lol-backend/generated/proto/planet/v1;planetv1\xa2\x02\x03PXX\xaa\x02\tPlanet.V1\xca\x02\tPlanet\\V1\xe2\x02\x15Planet\\V1\\GPBMetadata\xea\x02\n" + "Planet::V1b\x06proto3" @@ -2910,115 +3056,121 @@ 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, 46) 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 + (*GetAllegianceRequest)(nil), // 2: planet.v1.GetAllegianceRequest + (*GetAllegianceResponse)(nil), // 3: planet.v1.GetAllegianceResponse + (*Allegiance)(nil), // 4: planet.v1.Allegiance + (*ClickBudget)(nil), // 5: planet.v1.ClickBudget + (*ClickRequest)(nil), // 6: planet.v1.ClickRequest + (*ClickResponse)(nil), // 7: planet.v1.ClickResponse + (*GetBudgetRequest)(nil), // 8: planet.v1.GetBudgetRequest + (*GetBudgetResponse)(nil), // 9: planet.v1.GetBudgetResponse + (*MapDensityRequest)(nil), // 10: planet.v1.MapDensityRequest + (*MapDensityResponse)(nil), // 11: planet.v1.MapDensityResponse + (*GetMapRequest)(nil), // 12: planet.v1.GetMapRequest + (*GetMapResponse)(nil), // 13: planet.v1.GetMapResponse + (*TileShields)(nil), // 14: planet.v1.TileShields + (*ListenForEventsRequest)(nil), // 15: planet.v1.ListenForEventsRequest + (*PlanetEvent)(nil), // 16: planet.v1.PlanetEvent + (*GetFortressesRequest)(nil), // 17: planet.v1.GetFortressesRequest + (*GetFortressesResponse)(nil), // 18: planet.v1.GetFortressesResponse + (*Fortress)(nil), // 19: planet.v1.Fortress + (*LandmassFortified)(nil), // 20: planet.v1.LandmassFortified + (*ChargesHeld)(nil), // 21: planet.v1.ChargesHeld + (*GetChargesRequest)(nil), // 22: planet.v1.GetChargesRequest + (*GetChargesResponse)(nil), // 23: planet.v1.GetChargesResponse + (*GetBonusRulesRequest)(nil), // 24: planet.v1.GetBonusRulesRequest + (*GetBonusRulesResponse)(nil), // 25: planet.v1.GetBonusRulesResponse + (*TollStep)(nil), // 26: planet.v1.TollStep + (*BonusOffered)(nil), // 27: planet.v1.BonusOffered + (*BonusTaken)(nil), // 28: planet.v1.BonusTaken + (*ClaimBonusRequest)(nil), // 29: planet.v1.ClaimBonusRequest + (*ClaimBonusResponse)(nil), // 30: planet.v1.ClaimBonusResponse + (*QuizOffered)(nil), // 31: planet.v1.QuizOffered + (*OpenQuizRequest)(nil), // 32: planet.v1.OpenQuizRequest + (*OpenQuizResponse)(nil), // 33: planet.v1.OpenQuizResponse + (*AnswerQuizRequest)(nil), // 34: planet.v1.AnswerQuizRequest + (*AnswerQuizResponse)(nil), // 35: planet.v1.AnswerQuizResponse + (*UseRefillRequest)(nil), // 36: planet.v1.UseRefillRequest + (*UseRefillResponse)(nil), // 37: planet.v1.UseRefillResponse + (*GlobePoint)(nil), // 38: planet.v1.GlobePoint + (*DropBombRequest)(nil), // 39: planet.v1.DropBombRequest + (*DropBombResponse)(nil), // 40: planet.v1.DropBombResponse + (*BombDropped)(nil), // 41: planet.v1.BombDropped + (*TilesEnclosed)(nil), // 42: planet.v1.TilesEnclosed + (*TilesSpread)(nil), // 43: planet.v1.TilesSpread + (*PlaceShieldRequest)(nil), // 44: planet.v1.PlaceShieldRequest + (*PlaceShieldResponse)(nil), // 45: planet.v1.PlaceShieldResponse + (*Heartbeat)(nil), // 46: planet.v1.Heartbeat + (*TileUpdate)(nil), // 47: 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 - 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 - 1, // 20: planet.v1.AnswerQuizResponse.kind:type_name -> planet.v1.BonusKind - 18, // 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 - 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 - 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 - 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 - 27, // [27:27] is the sub-list for extension extendee - 0, // [0:27] is the sub-list for field type_name + 4, // 0: planet.v1.GetAllegianceResponse.allegiance:type_name -> planet.v1.Allegiance + 0, // 1: planet.v1.ClickBudget.shared_with:type_name -> planet.v1.SharedWith + 5, // 2: planet.v1.ClickResponse.budget:type_name -> planet.v1.ClickBudget + 5, // 3: planet.v1.GetBudgetResponse.budget:type_name -> planet.v1.ClickBudget + 14, // 4: planet.v1.GetMapResponse.shields:type_name -> planet.v1.TileShields + 47, // 5: planet.v1.PlanetEvent.tile_update:type_name -> planet.v1.TileUpdate + 46, // 6: planet.v1.PlanetEvent.heartbeat:type_name -> planet.v1.Heartbeat + 27, // 7: planet.v1.PlanetEvent.bonus_offered:type_name -> planet.v1.BonusOffered + 28, // 8: planet.v1.PlanetEvent.bonus_taken:type_name -> planet.v1.BonusTaken + 41, // 9: planet.v1.PlanetEvent.bomb_dropped:type_name -> planet.v1.BombDropped + 42, // 10: planet.v1.PlanetEvent.tiles_enclosed:type_name -> planet.v1.TilesEnclosed + 43, // 11: planet.v1.PlanetEvent.tiles_spread:type_name -> planet.v1.TilesSpread + 31, // 12: planet.v1.PlanetEvent.quiz_offered:type_name -> planet.v1.QuizOffered + 20, // 13: planet.v1.PlanetEvent.landmass_fortified:type_name -> planet.v1.LandmassFortified + 19, // 14: planet.v1.GetFortressesResponse.fortresses:type_name -> planet.v1.Fortress + 21, // 15: planet.v1.GetChargesResponse.charges:type_name -> planet.v1.ChargesHeld + 26, // 16: planet.v1.GetBonusRulesResponse.toll_steps:type_name -> planet.v1.TollStep + 1, // 17: planet.v1.BonusOffered.kind:type_name -> planet.v1.BonusKind + 1, // 18: planet.v1.BonusTaken.kind:type_name -> planet.v1.BonusKind + 1, // 19: planet.v1.ClaimBonusResponse.kind:type_name -> planet.v1.BonusKind + 21, // 20: planet.v1.ClaimBonusResponse.charges:type_name -> planet.v1.ChargesHeld + 1, // 21: planet.v1.AnswerQuizResponse.kind:type_name -> planet.v1.BonusKind + 21, // 22: planet.v1.AnswerQuizResponse.charges:type_name -> planet.v1.ChargesHeld + 5, // 23: planet.v1.UseRefillResponse.budget:type_name -> planet.v1.ClickBudget + 21, // 24: planet.v1.UseRefillResponse.charges:type_name -> planet.v1.ChargesHeld + 38, // 25: planet.v1.DropBombRequest.target:type_name -> planet.v1.GlobePoint + 38, // 26: planet.v1.BombDropped.point:type_name -> planet.v1.GlobePoint + 21, // 27: planet.v1.PlaceShieldResponse.charges:type_name -> planet.v1.ChargesHeld + 6, // 28: planet.v1.ClickService.Click:input_type -> planet.v1.ClickRequest + 8, // 29: planet.v1.ClickService.GetBudget:input_type -> planet.v1.GetBudgetRequest + 10, // 30: planet.v1.ClickService.MapDensity:input_type -> planet.v1.MapDensityRequest + 12, // 31: planet.v1.ClickService.GetMap:input_type -> planet.v1.GetMapRequest + 15, // 32: planet.v1.ClickService.ListenForEvents:input_type -> planet.v1.ListenForEventsRequest + 29, // 33: planet.v1.ClickService.ClaimBonus:input_type -> planet.v1.ClaimBonusRequest + 39, // 34: planet.v1.ClickService.DropBomb:input_type -> planet.v1.DropBombRequest + 36, // 35: planet.v1.ClickService.UseRefill:input_type -> planet.v1.UseRefillRequest + 22, // 36: planet.v1.ClickService.GetCharges:input_type -> planet.v1.GetChargesRequest + 24, // 37: planet.v1.ClickService.GetBonusRules:input_type -> planet.v1.GetBonusRulesRequest + 32, // 38: planet.v1.ClickService.OpenQuiz:input_type -> planet.v1.OpenQuizRequest + 34, // 39: planet.v1.ClickService.AnswerQuiz:input_type -> planet.v1.AnswerQuizRequest + 44, // 40: planet.v1.ClickService.PlaceShield:input_type -> planet.v1.PlaceShieldRequest + 17, // 41: planet.v1.ClickService.GetFortresses:input_type -> planet.v1.GetFortressesRequest + 2, // 42: planet.v1.ClickService.GetAllegiance:input_type -> planet.v1.GetAllegianceRequest + 7, // 43: planet.v1.ClickService.Click:output_type -> planet.v1.ClickResponse + 9, // 44: planet.v1.ClickService.GetBudget:output_type -> planet.v1.GetBudgetResponse + 11, // 45: planet.v1.ClickService.MapDensity:output_type -> planet.v1.MapDensityResponse + 13, // 46: planet.v1.ClickService.GetMap:output_type -> planet.v1.GetMapResponse + 16, // 47: planet.v1.ClickService.ListenForEvents:output_type -> planet.v1.PlanetEvent + 30, // 48: planet.v1.ClickService.ClaimBonus:output_type -> planet.v1.ClaimBonusResponse + 40, // 49: planet.v1.ClickService.DropBomb:output_type -> planet.v1.DropBombResponse + 37, // 50: planet.v1.ClickService.UseRefill:output_type -> planet.v1.UseRefillResponse + 23, // 51: planet.v1.ClickService.GetCharges:output_type -> planet.v1.GetChargesResponse + 25, // 52: planet.v1.ClickService.GetBonusRules:output_type -> planet.v1.GetBonusRulesResponse + 33, // 53: planet.v1.ClickService.OpenQuiz:output_type -> planet.v1.OpenQuizResponse + 35, // 54: planet.v1.ClickService.AnswerQuiz:output_type -> planet.v1.AnswerQuizResponse + 45, // 55: planet.v1.ClickService.PlaceShield:output_type -> planet.v1.PlaceShieldResponse + 18, // 56: planet.v1.ClickService.GetFortresses:output_type -> planet.v1.GetFortressesResponse + 3, // 57: planet.v1.ClickService.GetAllegiance:output_type -> planet.v1.GetAllegianceResponse + 43, // [43:58] is the sub-list for method output_type + 28, // [28:43] is the sub-list for method input_type + 28, // [28:28] is the sub-list for extension type_name + 28, // [28:28] is the sub-list for extension extendee + 0, // [0:28] is the sub-list for field type_name } func init() { file_planet_v1_planet_proto_init() } @@ -3026,7 +3178,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[14].OneofWrappers = []any{ (*PlanetEvent_TileUpdate)(nil), (*PlanetEvent_Heartbeat)(nil), (*PlanetEvent_BonusOffered)(nil), @@ -3043,7 +3195,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: 46, NumExtensions: 0, NumServices: 1, }, diff --git a/apps/backend/generated/proto/planet/v1/planetv1connect/planet.connect.go b/apps/backend/generated/proto/planet/v1/planetv1connect/planet.connect.go index d8ca7c623..95f7de21a 100644 --- a/apps/backend/generated/proto/planet/v1/planetv1connect/planet.connect.go +++ b/apps/backend/generated/proto/planet/v1/planetv1connect/planet.connect.go @@ -65,6 +65,9 @@ const ( // ClickServiceGetFortressesProcedure is the fully-qualified name of the ClickService's // GetFortresses RPC. ClickServiceGetFortressesProcedure = "/planet.v1.ClickService/GetFortresses" + // ClickServiceGetAllegianceProcedure is the fully-qualified name of the ClickService's + // GetAllegiance RPC. + ClickServiceGetAllegianceProcedure = "/planet.v1.ClickService/GetAllegiance" ) // ClickServiceClient is a client for the planet.v1.ClickService service. @@ -85,6 +88,8 @@ type ClickServiceClient interface { AnswerQuiz(context.Context, *connect.Request[v1.AnswerQuizRequest]) (*connect.Response[v1.AnswerQuizResponse], error) PlaceShield(context.Context, *connect.Request[v1.PlaceShieldRequest]) (*connect.Response[v1.PlaceShieldResponse], error) GetFortresses(context.Context, *connect.Request[v1.GetFortressesRequest]) (*connect.Response[v1.GetFortressesResponse], error) + // Not NO_SIDE_EFFECTS: the answer is about one caller now, and a cached one lies. + GetAllegiance(context.Context, *connect.Request[v1.GetAllegianceRequest]) (*connect.Response[v1.GetAllegianceResponse], error) } // NewClickServiceClient constructs a client for the planet.v1.ClickService service. By default, it @@ -186,6 +191,12 @@ func NewClickServiceClient(httpClient connect.HTTPClient, baseURL string, opts . connect.WithIdempotency(connect.IdempotencyNoSideEffects), connect.WithClientOptions(opts...), ), + getAllegiance: connect.NewClient[v1.GetAllegianceRequest, v1.GetAllegianceResponse]( + httpClient, + baseURL+ClickServiceGetAllegianceProcedure, + connect.WithSchema(clickServiceMethods.ByName("GetAllegiance")), + connect.WithClientOptions(opts...), + ), } } @@ -205,6 +216,7 @@ type clickServiceClient struct { answerQuiz *connect.Client[v1.AnswerQuizRequest, v1.AnswerQuizResponse] placeShield *connect.Client[v1.PlaceShieldRequest, v1.PlaceShieldResponse] getFortresses *connect.Client[v1.GetFortressesRequest, v1.GetFortressesResponse] + getAllegiance *connect.Client[v1.GetAllegianceRequest, v1.GetAllegianceResponse] } // Click calls planet.v1.ClickService.Click. @@ -277,6 +289,11 @@ func (c *clickServiceClient) GetFortresses(ctx context.Context, req *connect.Req return c.getFortresses.CallUnary(ctx, req) } +// GetAllegiance calls planet.v1.ClickService.GetAllegiance. +func (c *clickServiceClient) GetAllegiance(ctx context.Context, req *connect.Request[v1.GetAllegianceRequest]) (*connect.Response[v1.GetAllegianceResponse], error) { + return c.getAllegiance.CallUnary(ctx, req) +} + // ClickServiceHandler is an implementation of the planet.v1.ClickService service. type ClickServiceHandler interface { Click(context.Context, *connect.Request[v1.ClickRequest]) (*connect.Response[v1.ClickResponse], error) @@ -295,6 +312,8 @@ type ClickServiceHandler interface { AnswerQuiz(context.Context, *connect.Request[v1.AnswerQuizRequest]) (*connect.Response[v1.AnswerQuizResponse], error) PlaceShield(context.Context, *connect.Request[v1.PlaceShieldRequest]) (*connect.Response[v1.PlaceShieldResponse], error) GetFortresses(context.Context, *connect.Request[v1.GetFortressesRequest]) (*connect.Response[v1.GetFortressesResponse], error) + // Not NO_SIDE_EFFECTS: the answer is about one caller now, and a cached one lies. + GetAllegiance(context.Context, *connect.Request[v1.GetAllegianceRequest]) (*connect.Response[v1.GetAllegianceResponse], error) } // NewClickServiceHandler builds an HTTP handler from the service implementation. It returns the @@ -392,6 +411,12 @@ func NewClickServiceHandler(svc ClickServiceHandler, opts ...connect.HandlerOpti connect.WithIdempotency(connect.IdempotencyNoSideEffects), connect.WithHandlerOptions(opts...), ) + clickServiceGetAllegianceHandler := connect.NewUnaryHandler( + ClickServiceGetAllegianceProcedure, + svc.GetAllegiance, + connect.WithSchema(clickServiceMethods.ByName("GetAllegiance")), + connect.WithHandlerOptions(opts...), + ) return "/planet.v1.ClickService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case ClickServiceClickProcedure: @@ -422,6 +447,8 @@ func NewClickServiceHandler(svc ClickServiceHandler, opts ...connect.HandlerOpti clickServicePlaceShieldHandler.ServeHTTP(w, r) case ClickServiceGetFortressesProcedure: clickServiceGetFortressesHandler.ServeHTTP(w, r) + case ClickServiceGetAllegianceProcedure: + clickServiceGetAllegianceHandler.ServeHTTP(w, r) default: http.NotFound(w, r) } @@ -486,3 +513,7 @@ func (UnimplementedClickServiceHandler) PlaceShield(context.Context, *connect.Re func (UnimplementedClickServiceHandler) GetFortresses(context.Context, *connect.Request[v1.GetFortressesRequest]) (*connect.Response[v1.GetFortressesResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("planet.v1.ClickService.GetFortresses is not implemented")) } + +func (UnimplementedClickServiceHandler) GetAllegiance(context.Context, *connect.Request[v1.GetAllegianceRequest]) (*connect.Response[v1.GetAllegianceResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("planet.v1.ClickService.GetAllegiance is not implemented")) +} diff --git a/apps/backend/internal/planet/config.go b/apps/backend/internal/planet/config.go index a2eb7d10d..56e84d207 100644 --- a/apps/backend/internal/planet/config.go +++ b/apps/backend/internal/planet/config.go @@ -5,6 +5,7 @@ import ( "fmt" "github.com/raphoester/clickplanet.lol-backend/internal/antibot" + "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/allegiances/inmemory_allegiance_storage" "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/bonuses" "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/bonuses/inmemory_charge_storage" "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/clicks" @@ -27,6 +28,8 @@ type Config struct { ChargeStorage inmemory_charge_storage.Config + AllegianceStorage inmemory_allegiance_storage.Config + Ledger ledger.Config LedgerStorage inmemory_ledger_storage.Config diff --git a/apps/backend/internal/planet/internal/allegiances/allegiance.go b/apps/backend/internal/planet/internal/allegiances/allegiance.go new file mode 100644 index 000000000..f68ae95ca --- /dev/null +++ b/apps/backend/internal/planet/internal/allegiances/allegiance.go @@ -0,0 +1,48 @@ +package allegiances + +import ( + "time" + + "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/clicks" +) + +type Account string + +const NoAccount Account = "" + +func AccountOf(payer clicks.Payer) Account { + return Account(payer.Account) +} + +type Allegiance struct { + flag string + change Change +} + +func (a Allegiance) Pledged(flag string, at time.Time) (Allegiance, error) { + switch a.flag { + case flag: + return a, nil + case "": + return Allegiance{flag: flag}, nil + } + + if until := a.change.ChangeableAt(); at.Before(until) { + return Allegiance{}, Refusal{Flag: a.flag, Until: until} + } + + return Allegiance{flag: flag, change: ChangedAt(at)}, nil +} + +func (a Allegiance) TakeSnapshot() Snapshot { + return Snapshot{Flag: a.flag, ChangedAt: a.change.at} +} + +type Snapshot struct { + Flag string + ChangedAt time.Time +} + +func (s Snapshot) Restore() Allegiance { + return Allegiance{flag: s.Flag, change: ChangedAt(s.ChangedAt)} +} diff --git a/apps/backend/internal/planet/internal/allegiances/allegiance_test.go b/apps/backend/internal/planet/internal/allegiances/allegiance_test.go new file mode 100644 index 000000000..e9e627abd --- /dev/null +++ b/apps/backend/internal/planet/internal/allegiances/allegiance_test.go @@ -0,0 +1,63 @@ +package allegiances_test + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/allegiances" +) + +var at = time.Date(2026, 10, 17, 12, 0, 0, 0, time.UTC) + +func TestTheFirstFlagStartsNoWait(t *testing.T) { + first, err := allegiances.Allegiance{}.Pledged("fr", at) + require.NoError(t, err) + + assert.Equal(t, allegiances.Snapshot{Flag: "fr"}, first.TakeSnapshot()) +} + +func TestTheSameFlagChangesNothing(t *testing.T) { + kept := allegiances.Snapshot{Flag: "fr", ChangedAt: at}.Restore() + + again, err := kept.Pledged("fr", at.Add(time.Minute)) + require.NoError(t, err) + + assert.Equal(t, kept.TakeSnapshot(), again.TakeSnapshot()) +} + +func TestTheFirstChangeIsFreeAndStartsTheWait(t *testing.T) { + first := allegiances.Snapshot{Flag: "fr"}.Restore() + + changed, err := first.Pledged("de", at) + require.NoError(t, err) + + assert.Equal(t, allegiances.Snapshot{Flag: "de", ChangedAt: at}, changed.TakeSnapshot()) +} + +func TestAChangeWithinTheHourIsRefusedWithTheFlagAndUntilWhen(t *testing.T) { + kept := allegiances.Snapshot{Flag: "de", ChangedAt: at}.Restore() + + _, err := kept.Pledged("fr", at.Add(time.Hour-time.Nanosecond)) + + require.ErrorIs(t, err, allegiances.ErrChangedTooSoon) + var refusal allegiances.Refusal + require.ErrorAs(t, err, &refusal) + assert.Equal(t, allegiances.Refusal{Flag: "de", Until: at.Add(time.Hour)}, refusal) +} + +func TestAChangeAnHourLaterIsAllowedAndStartsTheWaitAgain(t *testing.T) { + kept := allegiances.Snapshot{Flag: "de", ChangedAt: at}.Restore() + + changed, err := kept.Pledged("fr", at.Add(time.Hour)) + require.NoError(t, err) + + assert.Equal(t, allegiances.Snapshot{Flag: "fr", ChangedAt: at.Add(time.Hour)}, changed.TakeSnapshot()) +} + +func TestTheWaitEndsAnHourAfterAChangeAndNeverAfterNone(t *testing.T) { + assert.Equal(t, at.Add(time.Hour), allegiances.ChangedAt(at).ChangeableAt()) + assert.True(t, allegiances.ChangedAt(time.Time{}).ChangeableAt().IsZero()) +} diff --git a/apps/backend/internal/planet/internal/allegiances/book.go b/apps/backend/internal/planet/internal/allegiances/book.go new file mode 100644 index 000000000..808027207 --- /dev/null +++ b/apps/backend/internal/planet/internal/allegiances/book.go @@ -0,0 +1,42 @@ +package allegiances + +import ( + "fmt" + + "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/clicks" + "github.com/raphoester/clickplanet.lol-backend/internal/shared/cptime" +) + +type Store interface { + Change(account Account, change func(Allegiance) (Allegiance, error)) error +} + +type CountryChecker interface { + CheckCountry(country string) bool +} + +func NewBook(store Store, countries CountryChecker, clock cptime.Clock) Book { + return Book{store: store, countries: countries, clock: clock} +} + +type Book struct { + store Store + countries CountryChecker + clock cptime.Clock +} + +func (b Book) Pledge(payer clicks.Payer, flag string) error { + account := AccountOf(payer) + if account == NoAccount || !b.countries.CheckCountry(flag) { + return nil + } + + at := b.clock.Now() + if err := b.store.Change(account, func(allegiance Allegiance) (Allegiance, error) { + return allegiance.Pledged(flag, at) + }); err != nil { + return fmt.Errorf("account %s may not play for %q: %w", account, flag, err) + } + + return nil +} diff --git a/apps/backend/internal/planet/internal/allegiances/book_test.go b/apps/backend/internal/planet/internal/allegiances/book_test.go new file mode 100644 index 000000000..62037221b --- /dev/null +++ b/apps/backend/internal/planet/internal/allegiances/book_test.go @@ -0,0 +1,79 @@ +package allegiances_test + +import ( + "log/slog" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/allegiances" + "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/allegiances/inmemory_allegiance_storage" + "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/clicks" + "github.com/raphoester/clickplanet.lol-backend/internal/shared/cpcountries" + "github.com/raphoester/clickplanet.lol-backend/internal/shared/cptime" +) + +const ( + alice = "01926c6e-7a4b-7c3d-8e9f-0a1b2c3d4e5f" + bob = "01926c6e-7a4b-7c3d-8e9f-0a1b2c3d4e60" +) + +func book(t *testing.T) (allegiances.Book, *inmemory_allegiance_storage.Storage, *cptime.FixedClock) { + t.Helper() + + storage := inmemory_allegiance_storage.New(inmemory_allegiance_storage.Config{}, + inmemory_allegiance_storage.NewMemoryPersistence(), slog.New(slog.DiscardHandler)) + clock := cptime.NewFixedClock(at) + + return allegiances.NewBook(storage, cpcountries.New(), clock), storage, clock +} + +func TestAnAccountIsHeldToItsFlagForAnHourAfterAChange(t *testing.T) { + b, storage, clock := book(t) + payer := clicks.Payer{Account: alice} + + require.NoError(t, b.Pledge(payer, "fr")) + require.NoError(t, b.Pledge(payer, "de")) + + clock.Advance(59 * time.Minute) + err := b.Pledge(payer, "fr") + require.ErrorIs(t, err, allegiances.ErrChangedTooSoon) + assert.Equal(t, allegiances.Snapshot{Flag: "de", ChangedAt: at}, storage.Snapshot(alice)) + + clock.Advance(time.Minute) + require.NoError(t, b.Pledge(payer, "fr")) + assert.Equal(t, allegiances.Snapshot{Flag: "fr", ChangedAt: at.Add(time.Hour)}, storage.Snapshot(alice)) +} + +func TestEachAccountHasItsOwnFlag(t *testing.T) { + b, storage, _ := book(t) + + require.NoError(t, b.Pledge(clicks.Payer{Account: alice, Scope: "1.2.3.4"}, "fr")) + require.NoError(t, b.Pledge(clicks.Payer{Account: alice, Scope: "1.2.3.4"}, "de")) + require.NoError(t, b.Pledge(clicks.Payer{Account: bob, Scope: "1.2.3.4"}, "fr")) + + assert.Equal(t, "de", storage.Snapshot(alice).Flag) + assert.Equal(t, "fr", storage.Snapshot(bob).Flag) +} + +func TestACallerWithNoAccountIsHeldToNothing(t *testing.T) { + b, storage, _ := book(t) + + require.NoError(t, b.Pledge(clicks.Payer{Scope: "1.2.3.4"}, "fr")) + require.NoError(t, b.Pledge(clicks.Payer{Scope: "1.2.3.4"}, "de")) + + assert.Equal(t, allegiances.Snapshot{}, storage.Snapshot(allegiances.NoAccount)) +} + +func TestAFlagThatIsNoCountryIsNotKept(t *testing.T) { + b, storage, _ := book(t) + payer := clicks.Payer{Account: alice} + require.NoError(t, b.Pledge(payer, "fr")) + require.NoError(t, b.Pledge(payer, "de")) + + require.NoError(t, b.Pledge(payer, "zz")) + + assert.Equal(t, "de", storage.Snapshot(alice).Flag) +} diff --git a/apps/backend/internal/planet/internal/allegiances/change.go b/apps/backend/internal/planet/internal/allegiances/change.go new file mode 100644 index 000000000..a01e6a1fb --- /dev/null +++ b/apps/backend/internal/planet/internal/allegiances/change.go @@ -0,0 +1,37 @@ +package allegiances + +import ( + "errors" + "fmt" + "time" +) + +const changeEvery = time.Hour + +var ErrChangedTooSoon = errors.New("the flag was changed too recently to change again") + +type Refusal struct { + Flag string + Until time.Time +} + +func (r Refusal) Error() string { + return fmt.Sprintf("%s: it plays for %q until %s", ErrChangedTooSoon, r.Flag, r.Until.UTC().Format(time.RFC3339)) +} + +func (r Refusal) Unwrap() error { return ErrChangedTooSoon } + +type Change struct { + at time.Time +} + +func ChangedAt(at time.Time) Change { + return Change{at: at} +} + +func (c Change) ChangeableAt() time.Time { + if c.at.IsZero() { + return time.Time{} + } + return c.at.Add(changeEvery) +} diff --git a/apps/backend/internal/planet/internal/allegiances/inmemory_allegiance_storage/persistence_testing.go b/apps/backend/internal/planet/internal/allegiances/inmemory_allegiance_storage/persistence_testing.go new file mode 100644 index 000000000..72cded50d --- /dev/null +++ b/apps/backend/internal/planet/internal/allegiances/inmemory_allegiance_storage/persistence_testing.go @@ -0,0 +1,66 @@ +//go:build testing + +package inmemory_allegiance_storage + +import ( + "context" + "maps" + "sync" + + "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/allegiances" +) + +type MemoryPersistence struct { + mu sync.Mutex + kept map[allegiances.Account]allegiances.Snapshot + failing error +} + +func NewMemoryPersistence() *MemoryPersistence { + return &MemoryPersistence{kept: map[allegiances.Account]allegiances.Snapshot{}} +} + +func (m *MemoryPersistence) Load(_ context.Context, visit func(allegiances.Account, allegiances.Snapshot)) error { + m.mu.Lock() + defer m.mu.Unlock() + + if m.failing != nil { + return m.failing + } + for account, kept := range m.kept { + visit(account, kept) + } + return nil +} + +func (m *MemoryPersistence) Save(_ context.Context, kept map[allegiances.Account]allegiances.Snapshot) error { + m.mu.Lock() + defer m.mu.Unlock() + + if m.failing != nil { + return m.failing + } + maps.Copy(m.kept, kept) + return nil +} + +func (m *MemoryPersistence) Stored() map[allegiances.Account]allegiances.Snapshot { + m.mu.Lock() + defer m.mu.Unlock() + + return maps.Clone(m.kept) +} + +func (m *MemoryPersistence) FailWith(err error) { + m.mu.Lock() + defer m.mu.Unlock() + + m.failing = err +} + +func (m *MemoryPersistence) Heal() { + m.mu.Lock() + defer m.mu.Unlock() + + m.failing = nil +} diff --git a/apps/backend/internal/planet/internal/allegiances/inmemory_allegiance_storage/storage.go b/apps/backend/internal/planet/internal/allegiances/inmemory_allegiance_storage/storage.go new file mode 100644 index 000000000..ddb4d8c2a --- /dev/null +++ b/apps/backend/internal/planet/internal/allegiances/inmemory_allegiance_storage/storage.go @@ -0,0 +1,153 @@ +package inmemory_allegiance_storage + +import ( + "context" + "fmt" + "log/slog" + "sync" + "time" + + "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/allegiances" + "github.com/raphoester/clickplanet.lol-backend/internal/shared/cpcolls" +) + +type Persistence interface { + Load(ctx context.Context, visit func(account allegiances.Account, kept allegiances.Snapshot)) error + Save(ctx context.Context, kept map[allegiances.Account]allegiances.Snapshot) error +} + +type Config struct { + FlushInterval time.Duration +} + +const ( + defaultFlushInterval = time.Second + flushTimeout = 10 * time.Second +) + +func (c Config) withDefaults() Config { + if c.FlushInterval <= 0 { + c.FlushInterval = defaultFlushInterval + } + + return c +} + +func New(config Config, persistence Persistence, logger *slog.Logger) *Storage { + return &Storage{ + config: config.withDefaults(), + persistence: persistence, + logger: logger, + kept: make(map[allegiances.Account]allegiances.Allegiance), + dirty: cpcolls.NewSet[allegiances.Account](), + } +} + +type Storage struct { + config Config + persistence Persistence + logger *slog.Logger + + mu sync.Mutex + kept map[allegiances.Account]allegiances.Allegiance + dirty *cpcolls.Set[allegiances.Account] + + flushMu sync.Mutex +} + +var _ allegiances.Store = (*Storage)(nil) + +func (s *Storage) Load(ctx context.Context) error { + s.mu.Lock() + defer s.mu.Unlock() + + err := s.persistence.Load(ctx, func(account allegiances.Account, kept allegiances.Snapshot) { + s.kept[account] = kept.Restore() + }) + if err != nil { + return fmt.Errorf("failed to read the stored allegiances: %w", err) + } + + s.logger.Info("loaded the allegiances", slog.Int("accounts", len(s.kept))) + + return nil +} + +func (s *Storage) Change(account allegiances.Account, change func(allegiances.Allegiance) (allegiances.Allegiance, error)) error { + s.mu.Lock() + defer s.mu.Unlock() + + before := s.kept[account] + after, err := change(before) + if err != nil { + return err + } + + if after.TakeSnapshot() != before.TakeSnapshot() { + s.kept[account] = after + s.dirty.Add(account) + } + + return nil +} + +func (s *Storage) Snapshot(account allegiances.Account) allegiances.Snapshot { + s.mu.Lock() + defer s.mu.Unlock() + + return s.kept[account].TakeSnapshot() +} + +func (s *Storage) Name() string { return "allegiance-storage" } + +func (s *Storage) Run(ctx context.Context) { + ticker := time.NewTicker(s.config.FlushInterval) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + s.flushOrLog(ctx) + case <-ctx.Done(): + s.flushOrLog(context.WithoutCancel(ctx)) + return + } + } +} + +func (s *Storage) flushOrLog(ctx context.Context) { + ctx, cancel := context.WithTimeout(ctx, flushTimeout) + defer cancel() + + if err := s.Flush(ctx); err != nil { + s.logger.Error("failed to flush the allegiances, retrying next tick", slog.Any("error", err)) + } +} + +func (s *Storage) Flush(ctx context.Context) error { + s.flushMu.Lock() + defer s.flushMu.Unlock() + + s.mu.Lock() + if s.dirty.Empty() { + s.mu.Unlock() + return nil + } + + changed := make(map[allegiances.Account]allegiances.Snapshot, s.dirty.Len()) + s.dirty.ForEach(func(account allegiances.Account) { changed[account] = s.kept[account].TakeSnapshot() }) + s.dirty = cpcolls.NewSet[allegiances.Account]() + s.mu.Unlock() + + if err := s.persistence.Save(ctx, changed); err != nil { + s.mu.Lock() + for account := range changed { + s.dirty.Add(account) + } + s.mu.Unlock() + + return fmt.Errorf("failed to save %d allegiances: %w", len(changed), err) + } + + return nil +} diff --git a/apps/backend/internal/planet/internal/allegiances/inmemory_allegiance_storage/storage_test.go b/apps/backend/internal/planet/internal/allegiances/inmemory_allegiance_storage/storage_test.go new file mode 100644 index 000000000..28438e28b --- /dev/null +++ b/apps/backend/internal/planet/internal/allegiances/inmemory_allegiance_storage/storage_test.go @@ -0,0 +1,86 @@ +package inmemory_allegiance_storage_test + +import ( + "errors" + "log/slog" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/allegiances" + "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/allegiances/inmemory_allegiance_storage" +) + +const ( + alice allegiances.Account = "01926c6e-7a4b-7c3d-8e9f-0a1b2c3d4e5f" + bob allegiances.Account = "01926c6e-7a4b-7c3d-8e9f-0a1b2c3d4e60" +) + +var at = time.Date(2026, 10, 17, 12, 0, 0, 0, time.UTC) + +func storageOver(persistence *inmemory_allegiance_storage.MemoryPersistence) *inmemory_allegiance_storage.Storage { + return inmemory_allegiance_storage.New(inmemory_allegiance_storage.Config{}, persistence, slog.New(slog.DiscardHandler)) +} + +func pledge(flag string, when time.Time) func(allegiances.Allegiance) (allegiances.Allegiance, error) { + return func(allegiance allegiances.Allegiance) (allegiances.Allegiance, error) { + return allegiance.Pledged(flag, when) + } +} + +func TestABootLoadsWhatWasKept(t *testing.T) { + persistence := inmemory_allegiance_storage.NewMemoryPersistence() + require.NoError(t, persistence.Save(t.Context(), map[allegiances.Account]allegiances.Snapshot{ + alice: {Flag: "de", ChangedAt: at}, + })) + storage := storageOver(persistence) + + require.NoError(t, storage.Load(t.Context())) + + assert.Equal(t, allegiances.Snapshot{Flag: "de", ChangedAt: at}, storage.Snapshot(alice)) + require.ErrorIs(t, storage.Change(alice, pledge("fr", at.Add(time.Minute))), allegiances.ErrChangedTooSoon) +} + +func TestAFailedLoadRefusesTheBoot(t *testing.T) { + persistence := inmemory_allegiance_storage.NewMemoryPersistence() + persistence.FailWith(errors.New("postgres is down")) + + assert.Error(t, storageOver(persistence).Load(t.Context())) +} + +func TestAFlushWritesWhatChangedSinceTheLast(t *testing.T) { + persistence := inmemory_allegiance_storage.NewMemoryPersistence() + storage := storageOver(persistence) + require.NoError(t, storage.Change(alice, pledge("fr", at))) + require.NoError(t, storage.Change(bob, pledge("de", at))) + + require.NoError(t, storage.Flush(t.Context())) + + assert.Equal(t, map[allegiances.Account]allegiances.Snapshot{alice: {Flag: "fr"}, bob: {Flag: "de"}}, persistence.Stored()) +} + +func TestARefusedChangeKeepsTheAllegiance(t *testing.T) { + storage := storageOver(inmemory_allegiance_storage.NewMemoryPersistence()) + require.NoError(t, storage.Change(alice, pledge("fr", at))) + require.NoError(t, storage.Change(alice, pledge("de", at))) + + require.Error(t, storage.Change(alice, pledge("fr", at.Add(time.Minute)))) + + assert.Equal(t, allegiances.Snapshot{Flag: "de", ChangedAt: at}, storage.Snapshot(alice)) +} + +func TestAFailedFlushIsWrittenOnTheNext(t *testing.T) { + persistence := inmemory_allegiance_storage.NewMemoryPersistence() + storage := storageOver(persistence) + require.NoError(t, storage.Change(alice, pledge("fr", at))) + + persistence.FailWith(errors.New("postgres is down")) + require.Error(t, storage.Flush(t.Context())) + + persistence.Heal() + require.NoError(t, storage.Flush(t.Context())) + + assert.Equal(t, map[allegiances.Account]allegiances.Snapshot{alice: {Flag: "fr"}}, persistence.Stored()) +} diff --git a/apps/backend/internal/planet/internal/allegiances/postgres_allegiance_store/store.go b/apps/backend/internal/planet/internal/allegiances/postgres_allegiance_store/store.go new file mode 100644 index 000000000..bd593c214 --- /dev/null +++ b/apps/backend/internal/planet/internal/allegiances/postgres_allegiance_store/store.go @@ -0,0 +1,75 @@ +package postgres_allegiance_store + +import ( + "context" + "database/sql" + "fmt" + "maps" + "slices" + + "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/allegiances" + "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/allegiances/inmemory_allegiance_storage" + "github.com/raphoester/clickplanet.lol-backend/internal/shared/cppg" +) + +func New(db cppg.QuerierBeginner) *Store { + return &Store{db: db} +} + +type Store struct { + db cppg.QuerierBeginner +} + +var _ inmemory_allegiance_storage.Persistence = (*Store)(nil) + +func (s *Store) Load(ctx context.Context, visit func(allegiances.Account, allegiances.Snapshot)) error { + rows, err := s.db.QueryContext(ctx, `SELECT account::text, flag, changed_at FROM allegiances`) + if err != nil { + return fmt.Errorf("failed to read the allegiances: %w", err) + } + defer func() { _ = rows.Close() }() + + for rows.Next() { + var ( + account string + flag string + changedAt sql.NullTime + ) + if err := rows.Scan(&account, &flag, &changedAt); err != nil { + return fmt.Errorf("failed to scan an allegiance: %w", err) + } + visit(allegiances.Account(account), allegiances.Snapshot{Flag: flag, ChangedAt: changedAt.Time}) + } + + if err := rows.Err(); err != nil { + return fmt.Errorf("failed to read the allegiances: %w", err) + } + + return nil +} + +func (s *Store) Save(ctx context.Context, kept map[allegiances.Account]allegiances.Snapshot) error { + tx, err := s.db.BeginTx(ctx, &sql.TxOptions{}) + if err != nil { + return fmt.Errorf("failed to save the allegiances: %w", err) + } + defer func() { _ = tx.Rollback() }() + + for _, account := range slices.Sorted(maps.Keys(kept)) { + allegiance := kept[account] + changedAt := sql.NullTime{Time: allegiance.ChangedAt.UTC(), Valid: !allegiance.ChangedAt.IsZero()} + + if _, err := tx.ExecContext(ctx, ` + INSERT INTO allegiances (account, flag, changed_at) VALUES ($1, $2, $3) + ON CONFLICT (account) DO UPDATE SET flag = EXCLUDED.flag, changed_at = EXCLUDED.changed_at + `, string(account), allegiance.Flag, changedAt); err != nil { + return fmt.Errorf("failed to write the allegiance of %s: %w", account, err) + } + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("failed to commit the allegiances: %w", err) + } + + return nil +} diff --git a/apps/backend/internal/planet/internal/allegiances/postgres_allegiance_store/store_test.go b/apps/backend/internal/planet/internal/allegiances/postgres_allegiance_store/store_test.go new file mode 100644 index 000000000..3d637e2d0 --- /dev/null +++ b/apps/backend/internal/planet/internal/allegiances/postgres_allegiance_store/store_test.go @@ -0,0 +1,78 @@ +package postgres_allegiance_store_test + +import ( + "testing" + "time" + + "github.com/stretchr/testify/suite" + + "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/allegiances" + "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/allegiances/postgres_allegiance_store" + "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/migrations" + "github.com/raphoester/clickplanet.lol-backend/internal/shared/cppg" +) + +func TestRunSuite(t *testing.T) { + suite.Run(t, new(testSuite)) +} + +type testSuite struct { + suite.Suite + db *cppg.Postgres + store *postgres_allegiance_store.Store +} + +func (s *testSuite) SetupSuite() { + s.db = cppg.StartTestServer(s.T()).OpenSchema(s.T(), "planet", migrations.FS) + s.store = postgres_allegiance_store.New(s.db) +} + +func (s *testSuite) SetupTest() { + s.Require().NoError(s.db.Purge(s.T().Context())) +} + +const ( + alice allegiances.Account = "01926c6e-7a4b-7c3d-8e9f-0a1b2c3d4e5f" + bob allegiances.Account = "01926c6e-7a4b-7c3d-8e9f-0a1b2c3d4e60" +) + +var at = time.Date(2026, 10, 17, 12, 0, 0, 0, time.UTC) + +func (s *testSuite) load() map[allegiances.Account]allegiances.Snapshot { + kept := map[allegiances.Account]allegiances.Snapshot{} + s.Require().NoError(s.store.Load(s.T().Context(), func(account allegiances.Account, allegiance allegiances.Snapshot) { + kept[account] = allegiance + })) + return kept +} + +func (s *testSuite) TestAnEmptyStoreLoadsNothing() { + s.Empty(s.load()) +} + +func (s *testSuite) TestSaveThenLoadEveryAllegiance() { + kept := map[allegiances.Account]allegiances.Snapshot{ + alice: {Flag: "fr"}, + bob: {Flag: "de", ChangedAt: at}, + } + + s.Require().NoError(s.store.Save(s.T().Context(), kept)) + + loaded := s.load() + s.Equal("fr", loaded[alice].Flag) + s.True(loaded[alice].ChangedAt.IsZero(), "a first flag keeps no time") + s.Equal("de", loaded[bob].Flag) + s.True(at.Equal(loaded[bob].ChangedAt)) +} + +func (s *testSuite) TestASecondSaveReplacesTheAllegiance() { + ctx := s.T().Context() + s.Require().NoError(s.store.Save(ctx, map[allegiances.Account]allegiances.Snapshot{alice: {Flag: "fr"}})) + + s.Require().NoError(s.store.Save(ctx, map[allegiances.Account]allegiances.Snapshot{alice: {Flag: "de", ChangedAt: at}})) + + loaded := s.load() + s.Len(loaded, 1) + s.Equal("de", loaded[alice].Flag) + s.True(at.Equal(loaded[alice].ChangedAt)) +} 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..978fba282 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 @@ -27,6 +27,10 @@ type CountryChecker interface { CheckCountry(country string) bool } +type Allegiances interface { + Pledge(payer clicks.Payer, flag string) error +} + type In struct { TileID uint32 CountryID string @@ -34,15 +38,16 @@ type In struct { Dud bool } -func New(shields Shields, tiles Tiles, countries CountryChecker, perTile int) *UseCase { - return &UseCase{shields: shields, tiles: tiles, countries: countries, perTile: perTile} +func New(shields Shields, tiles Tiles, countries CountryChecker, allegiances Allegiances, perTile int) *UseCase { + return &UseCase{shields: shields, tiles: tiles, countries: countries, allegiances: allegiances, perTile: perTile} } type UseCase struct { - shields Shields - tiles Tiles - countries CountryChecker - perTile int + shields Shields + tiles Tiles + countries CountryChecker + allegiances Allegiances + perTile int } func (u *UseCase) Execute(ctx context.Context, in In) (bonuses.Held, error) { @@ -59,7 +64,12 @@ func (u *UseCase) Execute(ctx context.Context, in In) (bonuses.Held, error) { return bonuses.Held{}, fmt.Errorf("tile %d: %w", in.TileID, err) } - holder := bonuses.HolderOf(clicks.PayerOf(ctx)) + payer := clicks.PayerOf(ctx) + if err := u.allegiances.Pledge(payer, in.CountryID); err != nil { + return bonuses.Held{}, err + } + + holder := bonuses.HolderOf(payer) if !u.shields.SpendShield(holder) { return bonuses.Held{}, ErrNoShield } 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..a6b1ed5fa 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 @@ -4,10 +4,13 @@ import ( "context" "log/slog" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/allegiances" + "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/allegiances/inmemory_allegiance_storage" "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/bonuses" "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/bonuses/inmemory_charge_storage" "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/bonuses/usecases/place_shield_usecase" @@ -15,6 +18,7 @@ import ( "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/clicks/inmemory_tile_storage" "github.com/raphoester/clickplanet.lol-backend/internal/shared/cpcountries" "github.com/raphoester/clickplanet.lol-backend/internal/shared/cpctx" + "github.com/raphoester/clickplanet.lol-backend/internal/shared/cptime" ) const caller bonuses.Holder = "a-player" @@ -22,6 +26,8 @@ const caller bonuses.Holder = "a-player" type fixture struct { charges *inmemory_charge_storage.Storage tiles *inmemory_tile_storage.Storage + book allegiances.Book + clock *cptime.FixedClock useCase *place_shield_usecase.UseCase } @@ -38,10 +44,16 @@ func setup(t *testing.T, shields int) fixture { inmemory_tile_storage.NewMemoryPersistence(map[uint32]string{7: "fr", 8: "de"}), slog.New(slog.DiscardHandler)) require.NoError(t, tiles.Load(t.Context())) + clock := cptime.NewFixedClock(time.Date(2026, 10, 17, 12, 0, 0, 0, time.UTC)) + book := allegiances.NewBook(inmemory_allegiance_storage.New(inmemory_allegiance_storage.Config{}, + inmemory_allegiance_storage.NewMemoryPersistence(), slog.New(slog.DiscardHandler)), cpcountries.New(), clock) + return fixture{ charges: charges, tiles: tiles, - useCase: place_shield_usecase.New(charges, tiles, cpcountries.New(), 2), + book: book, + clock: clock, + useCase: place_shield_usecase.New(charges, tiles, cpcountries.New(), book, 2), } } @@ -130,10 +142,25 @@ func (l lostRace) Shield(context.Context, uint32, string, int) error { return cl func TestAShieldThatCouldNotBePlacedIsGivenBack(t *testing.T) { f := setup(t, 3) - useCase := place_shield_usecase.New(f.charges, lostRace{f.tiles}, cpcountries.New(), 2) + useCase := place_shield_usecase.New(f.charges, lostRace{f.tiles}, cpcountries.New(), f.book, 2) _, err := useCase.Execute(played(t), place_shield_usecase.In{TileID: 7, CountryID: "fr"}) require.ErrorIs(t, err, clicks.ErrNotYourTile, "the tile changed hands between the check and the placement") assert.Equal(t, 3, f.charges.Held(caller).Shields) } + +func TestAShieldForAnotherFlagWithinTheHourIsRefusedAndSpendsNothing(t *testing.T) { + f := setup(t, 3) + _, err := f.place(t, place_shield_usecase.In{TileID: 7, CountryID: "fr"}) + require.NoError(t, err) + _, err = f.place(t, place_shield_usecase.In{TileID: 8, CountryID: "de"}) + require.NoError(t, err, "the first change is free") + + f.clock.Advance(30 * time.Minute) + _, err = f.place(t, place_shield_usecase.In{TileID: 7, CountryID: "fr"}) + + require.ErrorIs(t, err, allegiances.ErrChangedTooSoon) + assert.Equal(t, 1, f.charges.Held(caller).Shields) + assert.Equal(t, 1, f.tiles.Shields(7)) +} diff --git a/apps/backend/internal/planet/internal/clicks/usecases/click_usecase/allegiance_click/usecase.go b/apps/backend/internal/planet/internal/clicks/usecases/click_usecase/allegiance_click/usecase.go new file mode 100644 index 000000000..1260883d9 --- /dev/null +++ b/apps/backend/internal/planet/internal/clicks/usecases/click_usecase/allegiance_click/usecase.go @@ -0,0 +1,29 @@ +package allegiance_click + +import ( + "context" + + "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/clicks" + "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/clicks/usecases/click_usecase" +) + +type Allegiances interface { + Pledge(payer clicks.Payer, flag string) error +} + +func New(implementation click_usecase.IUseCase, allegiances Allegiances) *UseCase { + return &UseCase{implementation: implementation, allegiances: allegiances} +} + +type UseCase struct { + implementation click_usecase.IUseCase + allegiances Allegiances +} + +func (u *UseCase) Execute(ctx context.Context, in click_usecase.In) (click_usecase.Out, error) { + if err := u.allegiances.Pledge(clicks.PayerOf(ctx), in.CountryID); err != nil { + return click_usecase.Out{}, err + } + + return u.implementation.Execute(ctx, in) +} diff --git a/apps/backend/internal/planet/internal/clicks/usecases/click_usecase/allegiance_click/usecase_test.go b/apps/backend/internal/planet/internal/clicks/usecases/click_usecase/allegiance_click/usecase_test.go new file mode 100644 index 000000000..2c92128d2 --- /dev/null +++ b/apps/backend/internal/planet/internal/clicks/usecases/click_usecase/allegiance_click/usecase_test.go @@ -0,0 +1,69 @@ +package allegiance_click_test + +import ( + "context" + "log/slog" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/allegiances" + "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/allegiances/inmemory_allegiance_storage" + "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/allegiance_click" + "github.com/raphoester/clickplanet.lol-backend/internal/shared/cpcountries" + "github.com/raphoester/clickplanet.lol-backend/internal/shared/cpctx" + "github.com/raphoester/clickplanet.lol-backend/internal/shared/cptime" +) + +const alice = "01926c6e-7a4b-7c3d-8e9f-0a1b2c3d4e5f" + +type countingClicks struct { + seen []click_usecase.In +} + +func (c *countingClicks) Execute(_ context.Context, in click_usecase.In) (click_usecase.Out, error) { + c.seen = append(c.seen, in) + return click_usecase.Out{}, nil +} + +func setup(t *testing.T) (*allegiance_click.UseCase, *countingClicks, *cptime.FixedClock) { + t.Helper() + + storage := inmemory_allegiance_storage.New(inmemory_allegiance_storage.Config{}, + inmemory_allegiance_storage.NewMemoryPersistence(), slog.New(slog.DiscardHandler)) + clock := cptime.NewFixedClock(time.Date(2026, 10, 17, 12, 0, 0, 0, time.UTC)) + inner := &countingClicks{} + + return allegiance_click.New(inner, allegiances.NewBook(storage, cpcountries.New(), clock)), inner, clock +} + +func clickFor(t *testing.T, useCase *allegiance_click.UseCase, flag string) error { + t.Helper() + + _, err := useCase.Execute(cpctx.AddAccountToContext(t.Context(), alice), click_usecase.In{TileID: 7, CountryID: flag}) + return err +} + +func TestAClickForTheFlagHeldReachesTheMap(t *testing.T) { + useCase, inner, _ := setup(t) + + require.NoError(t, clickFor(t, useCase, "fr")) + require.NoError(t, clickFor(t, useCase, "fr")) + + assert.Len(t, inner.seen, 2) +} + +func TestAClickForAnotherFlagWithinTheHourReachesNothing(t *testing.T) { + useCase, inner, clock := setup(t) + require.NoError(t, clickFor(t, useCase, "fr")) + require.NoError(t, clickFor(t, useCase, "de")) + + clock.Advance(30 * time.Minute) + err := clickFor(t, useCase, "fr") + + require.ErrorIs(t, err, allegiances.ErrChangedTooSoon) + assert.Len(t, inner.seen, 2, "the throttle and the map are never asked") +} diff --git a/apps/backend/internal/planet/internal/migrations/20261017130000_allegiances.down.sql b/apps/backend/internal/planet/internal/migrations/20261017130000_allegiances.down.sql new file mode 100644 index 000000000..1252c8b7c --- /dev/null +++ b/apps/backend/internal/planet/internal/migrations/20261017130000_allegiances.down.sql @@ -0,0 +1 @@ +DROP TABLE allegiances; diff --git a/apps/backend/internal/planet/internal/migrations/20261017130000_allegiances.up.sql b/apps/backend/internal/planet/internal/migrations/20261017130000_allegiances.up.sql new file mode 100644 index 000000000..f583c28cc --- /dev/null +++ b/apps/backend/internal/planet/internal/migrations/20261017130000_allegiances.up.sql @@ -0,0 +1,5 @@ +CREATE TABLE allegiances ( + account uuid PRIMARY KEY, + flag text NOT NULL CHECK (flag <> ''), + changed_at timestamptz +); diff --git a/apps/backend/internal/planet/internal/planetv1controller/allegiancemessage/allegiance.go b/apps/backend/internal/planet/internal/planetv1controller/allegiancemessage/allegiance.go new file mode 100644 index 000000000..5305f8131 --- /dev/null +++ b/apps/backend/internal/planet/internal/planetv1controller/allegiancemessage/allegiance.go @@ -0,0 +1,34 @@ +package allegiancemessage + +import ( + "errors" + "time" + + "connectrpc.com/connect" + + planetv1 "github.com/raphoester/clickplanet.lol-backend/generated/proto/planet/v1" + "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/allegiances" +) + +func Encode(flag string, changeableAt time.Time) *planetv1.Allegiance { + allegiance := &planetv1.Allegiance{CountryId: flag} + if !changeableAt.IsZero() { + allegiance.ChangeableAtUnixMs = changeableAt.UnixMilli() + } + + return allegiance +} + +func Refusal(err error) (*connect.Error, bool) { + var refusal allegiances.Refusal + if !errors.As(err, &refusal) { + return nil, false + } + + refused := connect.NewError(connect.CodeFailedPrecondition, allegiances.ErrChangedTooSoon) + if detail, detailErr := connect.NewErrorDetail(Encode(refusal.Flag, refusal.Until)); detailErr == nil { + refused.AddDetail(detail) + } + + return refused, true +} 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..3133351fd 100644 --- a/apps/backend/internal/planet/internal/planetv1controller/click_handler/errors.go +++ b/apps/backend/internal/planet/internal/planetv1controller/click_handler/errors.go @@ -6,6 +6,7 @@ import ( "connectrpc.com/connect" "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/allegiancemessage" "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/planetv1controller/clickbudget" ) @@ -20,6 +21,10 @@ func toConnect(err error, out click_usecase.Out) error { return throttled(err, out) } + if refused, ok := allegiancemessage.Refusal(err); ok { + 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/click_handler/handler_test.go b/apps/backend/internal/planet/internal/planetv1controller/click_handler/handler_test.go index 7f2f89998..74884affe 100644 --- a/apps/backend/internal/planet/internal/planetv1controller/click_handler/handler_test.go +++ b/apps/backend/internal/planet/internal/planetv1controller/click_handler/handler_test.go @@ -3,13 +3,17 @@ package click_handler_test import ( "context" "errors" + "fmt" "testing" + "time" "connectrpc.com/connect" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" planetv1 "github.com/raphoester/clickplanet.lol-backend/generated/proto/planet/v1" + "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/allegiances" "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/click_handler" @@ -121,3 +125,18 @@ func TestClickMapsTheErrors(t *testing.T) { "the handler picked no code: dressing this up would hide it from the interceptor's log") }) } + +func TestAClickRefusedForItsFlagSaysWhichFlagAndUntilWhen(t *testing.T) { + until := time.Date(2026, 10, 17, 13, 0, 0, 0, time.UTC) + useCase := &stubUseCase{err: fmt.Errorf("wrapped: %w", allegiances.Refusal{Flag: "de", Until: until})} + + _, err := clickOn(t, useCase, &planetv1.ClickRequest{TileId: 1, CountryId: "fr"}) + + require.Equal(t, connect.CodeFailedPrecondition, connect.CodeOf(err)) + var connectErr *connect.Error + require.ErrorAs(t, err, &connectErr) + require.Len(t, connectErr.Details(), 1) + value, err := connectErr.Details()[0].Value() + require.NoError(t, err) + assert.True(t, proto.Equal(&planetv1.Allegiance{CountryId: "de", ChangeableAtUnixMs: until.UnixMilli()}, value)) +} diff --git a/apps/backend/internal/planet/internal/planetv1controller/click_service.go b/apps/backend/internal/planet/internal/planetv1controller/click_service.go index 6b45729e9..95521ed3b 100644 --- a/apps/backend/internal/planet/internal/planetv1controller/click_service.go +++ b/apps/backend/internal/planet/internal/planetv1controller/click_service.go @@ -6,6 +6,7 @@ import ( "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/planetv1controller/claim_bonus_handler" "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/planetv1controller/click_handler" "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/planetv1controller/drop_bomb_handler" + "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/planetv1controller/get_allegiance_handler" "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/planetv1controller/get_bonus_rules_handler" "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/planetv1controller/get_budget_handler" "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/planetv1controller/get_charges_handler" @@ -33,6 +34,7 @@ type ClickService struct { answer_quiz_handler.AnswerQuizHandler place_shield_handler.PlaceShieldHandler get_fortresses_handler.GetFortressesHandler + get_allegiance_handler.GetAllegianceHandler } var _ planetv1connect.ClickServiceHandler = ClickService{} diff --git a/apps/backend/internal/planet/internal/planetv1controller/get_allegiance_handler/allegiance_query/query_memory.go b/apps/backend/internal/planet/internal/planetv1controller/get_allegiance_handler/allegiance_query/query_memory.go new file mode 100644 index 000000000..04983f6ab --- /dev/null +++ b/apps/backend/internal/planet/internal/planetv1controller/get_allegiance_handler/allegiance_query/query_memory.go @@ -0,0 +1,34 @@ +package allegiance_query + +import ( + planetv1 "github.com/raphoester/clickplanet.lol-backend/generated/proto/planet/v1" + "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/allegiances" + "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/planetv1controller/allegiancemessage" +) + +type Allegiances interface { + Snapshot(account allegiances.Account) allegiances.Snapshot +} + +func NewMemoryQuery(allegiances Allegiances) *MemoryQuery { + return &MemoryQuery{allegiances: allegiances} +} + +type MemoryQuery struct { + allegiances Allegiances +} + +func (q *MemoryQuery) Allegiance(account allegiances.Account) *planetv1.GetAllegianceResponse { + if account == allegiances.NoAccount { + return &planetv1.GetAllegianceResponse{} + } + + kept := q.allegiances.Snapshot(account) + if kept.Flag == "" { + return &planetv1.GetAllegianceResponse{} + } + + return &planetv1.GetAllegianceResponse{ + Allegiance: allegiancemessage.Encode(kept.Flag, allegiances.ChangedAt(kept.ChangedAt).ChangeableAt()), + } +} diff --git a/apps/backend/internal/planet/internal/planetv1controller/get_allegiance_handler/allegiance_query/query_memory_test.go b/apps/backend/internal/planet/internal/planetv1controller/get_allegiance_handler/allegiance_query/query_memory_test.go new file mode 100644 index 000000000..7ab1a5fb5 --- /dev/null +++ b/apps/backend/internal/planet/internal/planetv1controller/get_allegiance_handler/allegiance_query/query_memory_test.go @@ -0,0 +1,44 @@ +package allegiance_query_test + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "google.golang.org/protobuf/proto" + + planetv1 "github.com/raphoester/clickplanet.lol-backend/generated/proto/planet/v1" + "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/allegiances" + "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/planetv1controller/get_allegiance_handler/allegiance_query" +) + +const alice allegiances.Account = "01926c6e-7a4b-7c3d-8e9f-0a1b2c3d4e5f" + +type stubAllegiances map[allegiances.Account]allegiances.Snapshot + +func (s stubAllegiances) Snapshot(account allegiances.Account) allegiances.Snapshot { + return s[account] +} + +func TestAnAllegianceSaysWhenItMayChange(t *testing.T) { + changed := time.Date(2026, 10, 17, 12, 0, 0, 0, time.UTC) + query := allegiance_query.NewMemoryQuery(stubAllegiances{alice: {Flag: "de", ChangedAt: changed}}) + + assert.True(t, proto.Equal(&planetv1.GetAllegianceResponse{Allegiance: &planetv1.Allegiance{ + CountryId: "de", ChangeableAtUnixMs: changed.Add(time.Hour).UnixMilli(), + }}, query.Allegiance(alice))) +} + +func TestAFirstFlagMayChangeNow(t *testing.T) { + query := allegiance_query.NewMemoryQuery(stubAllegiances{alice: {Flag: "fr"}}) + + assert.True(t, proto.Equal(&planetv1.GetAllegianceResponse{Allegiance: &planetv1.Allegiance{CountryId: "fr"}}, + query.Allegiance(alice))) +} + +func TestNoFlagYetAndNoAccountAnswerNoAllegiance(t *testing.T) { + query := allegiance_query.NewMemoryQuery(stubAllegiances{alice: {Flag: "fr"}}) + + assert.Nil(t, query.Allegiance("01926c6e-7a4b-7c3d-8e9f-0a1b2c3d4e60").GetAllegiance()) + assert.Nil(t, query.Allegiance(allegiances.NoAccount).GetAllegiance()) +} diff --git a/apps/backend/internal/planet/internal/planetv1controller/get_allegiance_handler/handler.go b/apps/backend/internal/planet/internal/planetv1controller/get_allegiance_handler/handler.go new file mode 100644 index 000000000..ee8e7340d --- /dev/null +++ b/apps/backend/internal/planet/internal/planetv1controller/get_allegiance_handler/handler.go @@ -0,0 +1,30 @@ +package get_allegiance_handler + +import ( + "context" + + "connectrpc.com/connect" + + planetv1 "github.com/raphoester/clickplanet.lol-backend/generated/proto/planet/v1" + "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/allegiances" + "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/clicks" +) + +type Query interface { + Allegiance(account allegiances.Account) *planetv1.GetAllegianceResponse +} + +func New(query Query) GetAllegianceHandler { + return GetAllegianceHandler{query: query} +} + +type GetAllegianceHandler struct { + query Query +} + +func (h GetAllegianceHandler) GetAllegiance( + ctx context.Context, + _ *connect.Request[planetv1.GetAllegianceRequest], +) (*connect.Response[planetv1.GetAllegianceResponse], error) { + return connect.NewResponse(h.query.Allegiance(allegiances.AccountOf(clicks.PayerOf(ctx)))), 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..0e6fa2c0c 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 @@ -10,6 +10,7 @@ import ( "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" + "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/planetv1controller/allegiancemessage" "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/planetv1controller/chargesheld" ) @@ -34,6 +35,10 @@ func (h PlaceShieldHandler) PlaceShield( CountryID: req.Msg.GetCountryId(), }) + if refused, ok := allegiancemessage.Refusal(err); ok { + 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/session_interceptor.go b/apps/backend/internal/planet/internal/planetv1controller/session_interceptor.go index 00827c8b8..58545e38d 100644 --- a/apps/backend/internal/planet/internal/planetv1controller/session_interceptor.go +++ b/apps/backend/internal/planet/internal/planetv1controller/session_interceptor.go @@ -48,5 +48,6 @@ func NewSessionReaderInterceptor(verifier ClickSessionVerifier, clock cptime.Clo cpconnect.Identified(planetv1connect.ClickServiceGetBudgetProcedure), cpconnect.Identified(planetv1connect.ClickServiceGetChargesProcedure), cpconnect.Identified(planetv1connect.ClickServiceListenForEventsProcedure), + cpconnect.Identified(planetv1connect.ClickServiceGetAllegianceProcedure), ) } diff --git a/apps/backend/internal/planet/module.go b/apps/backend/internal/planet/module.go index 06b195663..d6115b98b 100644 --- a/apps/backend/internal/planet/module.go +++ b/apps/backend/internal/planet/module.go @@ -13,6 +13,9 @@ import ( "github.com/raphoester/clickplanet.lol-backend/generated/proto/planet/v1/planetv1connect" "github.com/raphoester/clickplanet.lol-backend/internal/antibot" + "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/allegiances" + "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/allegiances/inmemory_allegiance_storage" + "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/allegiances/postgres_allegiance_store" "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/bonuses" "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/bonuses/inmemory_charge_storage" "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/bonuses/postgres_charge_store" @@ -36,6 +39,7 @@ import ( "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/clicks/inmemory_tile_storage" "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/clicks/postgres_tile_store" "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/allegiance_click" "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/bonus_click" @@ -78,6 +82,8 @@ import ( "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/planetv1controller/click_handler" "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/planetv1controller/drop_bomb_handler" "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/planetv1controller/find_players_handler" + "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/planetv1controller/get_allegiance_handler" + "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/planetv1controller/get_allegiance_handler/allegiance_query" "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/planetv1controller/get_bonus_rules_handler" "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/planetv1controller/get_budget_handler" "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/planetv1controller/get_charges_handler" @@ -178,8 +184,15 @@ func NewModule(config Config) cpbootstrap.Module { return fmt.Errorf("failed to load the charges: %w", err) } + allegianceStorage := inmemory_allegiance_storage.New(config.AllegianceStorage, + postgres_allegiance_store.New(db), props.Logger) + if err := allegianceStorage.Load(ctx); err != nil { + _ = db.Close() + return fmt.Errorf("failed to load the allegiances: %w", err) + } + // Not a closer: closers run before the runners' last flush. - props.Runners.Add(cppg.CloseAfter(db, props.Logger, tilesStorage, takings, charges, deletions)) + props.Runners.Add(cppg.CloseAfter(db, props.Logger, tilesStorage, takings, charges, allegianceStorage, deletions)) props.Runners.Add(ledger.NewRetention(config.Ledger, takings, clock)) props.Runners.Add(relay_fortifications_usecase.New(tilesStorage, props.Events, clock, props.Logger)) @@ -189,6 +202,8 @@ func NewModule(config Config) cpbootstrap.Module { pricer := clicks.NewToll(config.Toll, tilesStorage) + book := allegiances.NewBook(allegianceStorage, countries, clock) + shielding := clicks.NewShielding(tilesStorage) writer := ledger.NewRecording(tilesStorage, clicks.NewClaiming(tilesStorage, config.Bonus.ShieldsPerTile()), @@ -238,6 +253,9 @@ func NewModule(config Config) cpbootstrap.Module { // Outside the shadow ban, or a banned caller would stop seeing 429s and know. clickUseCase = throttle_click.New(clickUseCase, limiter, pricer, buckets) + // Outside the throttle, so a click refused for its flag spends nothing. + clickUseCase = allegiance_click.New(clickUseCase, book) + clickUseCase = antibot_attempt_click.New(clickUseCase, guard, geography, clock) adminBatch := config.TilesStorage.SubscriberBuffer / 4 @@ -368,8 +386,9 @@ func NewModule(config Config) cpbootstrap.Module { OpenQuizHandler: open_quiz_handler.New(openQuiz), AnswerQuizHandler: answer_quiz_handler.New(answerQuiz), PlaceShieldHandler: place_shield_handler.New(antibot_place_shield.New( - place_shield_usecase.New(charges, writer, countries, config.Bonus.ShieldsPerTile()), guard)), + place_shield_usecase.New(charges, writer, countries, book, config.Bonus.ShieldsPerTile()), guard)), GetFortressesHandler: get_fortresses_handler.New(fortresses_query.NewMemoryQuery(tilesStorage)), + GetAllegianceHandler: get_allegiance_handler.New(allegiance_query.NewMemoryQuery(allegianceStorage)), } return props.RPC.Mount(func(options ...connect.HandlerOption) (string, http.Handler) { diff --git a/apps/frontend/CLAUDE.md b/apps/frontend/CLAUDE.md index d3c81c26d..8a4f08831 100644 --- a/apps/frontend/CLAUDE.md +++ b/apps/frontend/CLAUDE.md @@ -63,7 +63,8 @@ somebody else's bomb, spread click or shield. `fakeBackend.takeLandmass(landmass takes a landmass for a flag, shields and all, and leaves the last `leave` tiles to click by hand (it answers them); the fake fortifies as the server does, and every landmass starts locked to France. `fakeBackend.shareClicks("guests")` (or -`"network"`) reads the bucket as shared, and `fakeBackend.shareClicks()` as the +`"network"`) reads the bucket as shared, and `fakeBackend.unlockFlag()` ends the +wait for another flag (see [The flag played for](#the-flag-played-for)), 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. `localStorage.removeItem("clickplanet-bonus-guide")` makes the bonuses new again. @@ -2374,6 +2375,28 @@ it, and if the echo arrives first the rollback is already a no-op. A transport fault rolls back with no sound and no dialog; the card from [Connection health](#connection-health) is what says why. +## The flag played for + +**The server holds each account to one flag for an hour after a change** (the +backend's CLAUDE.md, Allegiance). A click or a shield for another flag in that hour +is refused. The client says so before the player tries. + +- `AllegianceSource` (`backends/backend.ts`) is the flag held, `Allegiance` in + `domain/allegiance.ts`: the country and `changeableAt`, undefined for a flag + never changed. `lockOf` is whether it holds now. +- **`PlanetBackend` reads `GetAllegiance`** at load with the identity token, again + when a click goes out under a new token (`followSession`), and after an accepted + click for another flag than the one it holds: that click is the change. A refusal + carries the flag held in its detail, which `PlanetBackend` keeps + (`allegianceDetailOf`) and turns into `AllegianceLockedError`. A server without + the procedure holds nobody to anything. +- **While the flag is held, the picked flag is the held one**: `Viewer` sets the + country back to it (local storage, a share link, another device). The picker + (`CountryPicker`) shows no list then, only when another country may be played. +- **A refused click is rolled back and plays the refusal**, with no dialog + (`reportClickFailure`); a refused shield says nothing. The country snaps back. +- `FakeBackend` holds the flag the same way. + ## Charges A bonus box holds a **charge**, which has no clock and is never used on its own: diff --git a/apps/frontend/src/app/App.tsx b/apps/frontend/src/app/App.tsx index 8c1091c39..dd013e937 100644 --- a/apps/frontend/src/app/App.tsx +++ b/apps/frontend/src/app/App.tsx @@ -19,6 +19,7 @@ export default function App(props: AppProps) { bomber={props.bomber} refiller={props.refiller} shielder={props.shielder} + allegiance={props.allegiance} chatBackend={props.chatBackend} account={props.account} presence={props.presence} diff --git a/apps/frontend/src/app/CountryPicker.css b/apps/frontend/src/app/CountryPicker.css new file mode 100644 index 000000000..b90879d02 --- /dev/null +++ b/apps/frontend/src/app/CountryPicker.css @@ -0,0 +1,5 @@ +.country-picker-locked { + margin: 0 0 var(--space-6); + font-size: var(--text-m); + color: var(--text-soft); +} diff --git a/apps/frontend/src/app/CountryPicker.tsx b/apps/frontend/src/app/CountryPicker.tsx index 4e88c86dd..749b1b410 100644 --- a/apps/frontend/src/app/CountryPicker.tsx +++ b/apps/frontend/src/app/CountryPicker.tsx @@ -1,12 +1,24 @@ import {Countries, Country} from "../domain/countries.ts"; +import {Allegiance, lockOf} from "../domain/allegiance.ts"; import SelectWithSearch from "./components/SelectWithSearch.tsx"; +import "./CountryPicker.css" export type CountryPickerProps = { country: Country, setCountry: (country: Country) => void, + allegiance?: Allegiance, } +const changeTime = new Intl.DateTimeFormat(undefined, {hour: "2-digit", minute: "2-digit"}) + export default function CountryPicker(props: CountryPickerProps) { + const locked = lockOf(props.allegiance, Date.now()) + if (locked?.changeableAt !== undefined) { + return

+ You can play for another country at {changeTime.format(locked.changeableAt)}. +

+ } + return screen.getByRole("button", {name}) const tab = (name: string) => screen.getByRole("tab", {name}) describe("Menu", () => { + it("says when another country may be played while the flag is held, and offers none", async () => { + const user = userEvent.setup() + const changeableAt = Date.now() + 30 * 60 * 1000 + render() + + await user.click(button("Change")) + + const time = new Intl.DateTimeFormat(undefined, {hour: "2-digit", minute: "2-digit"}).format(changeableAt) + expect(screen.getByRole("status").textContent).toBe(`You can play for another country at ${time}.`) + expect(countryPanel()).toBeNull() + }) + + it("offers every country once the wait is over", async () => { + const user = userEvent.setup() + render() + + await user.click(button("Change")) + + expect(countryPanel()).not.toBeNull() + }) + it("starts on the leaderboard, with the picker and About closed", () => { setup([entry("fr", 500)]) diff --git a/apps/frontend/src/app/Menu.tsx b/apps/frontend/src/app/Menu.tsx index 64d0c70e8..8658b5c50 100644 --- a/apps/frontend/src/app/Menu.tsx +++ b/apps/frontend/src/app/Menu.tsx @@ -4,6 +4,7 @@ import {LeaderboardEntry, rankOf} from "../domain/leaderboard.ts"; import {countryLines, CountryOrder, shownOrder} from "../domain/race.ts"; import {NO_TILE_DELTAS, TileDeltas} from "../domain/tileDeltas.ts"; import {Race} from "../backends/standings.ts"; +import {Allegiance} from "../domain/allegiance.ts"; import Leaderboard, {OrderSwitch} from "./Leaderboard.tsx"; import CountryPicker from "./CountryPicker.tsx"; import MenuPanel from "./components/MenuPanel.tsx"; @@ -28,6 +29,7 @@ export type MenuTab = "board" | "you" | "share" | "settings" export type BoardPlaceProps = { country: Country, setCountry: (country: Country) => void, + allegiance?: Allegiance, leaderboard: LeaderboardEntry[], tileDeltas?: TileDeltas, tilesCount: number, @@ -85,7 +87,7 @@ export default function Menu(props: MenuProps) { {picker.picking ? - { + { props.setCountry(country) picker.close() }}/> @@ -205,7 +207,7 @@ export function BoardSheet({onClose, ...props}: BoardPlaceProps & {onClose: () = return {picker.picking ? - { + { props.setCountry(country) picker.close() }}/> diff --git a/apps/frontend/src/app/viewer/Viewer.tsx b/apps/frontend/src/app/viewer/Viewer.tsx index 96ec41a58..4879e55c4 100644 --- a/apps/frontend/src/app/viewer/Viewer.tsx +++ b/apps/frontend/src/app/viewer/Viewer.tsx @@ -1,5 +1,6 @@ import {useEffect, useRef, useState} from 'react'; import { + AllegianceSource, BankFullError, Bomber, BonusListener, @@ -39,7 +40,9 @@ import SharePlace from "../share/SharePlace.tsx"; import SharePreview from "../share/SharePreview.tsx"; import {useSharePicture} from "../share/useSharePicture.ts"; import {shareStats} from "../../domain/shareCard.ts"; -import {Country} from "../../domain/countries.ts"; +import {Countries, Country} from "../../domain/countries.ts"; +import {lockOf} from "../../domain/allegiance.ts"; +import {useAllegiance} from "./useAllegiance.ts"; import {ClickBudgetSource, now as budgetNow, tokensAt} from "../../backends/clickBudget.ts"; import {useClickBudget} from './useClickBudget.ts'; import {useCountryStorage} from './useCountryStorage.ts'; @@ -98,6 +101,7 @@ export type ViewerProps = { bomber?: Bomber refiller?: Refiller shielder?: Shielder + allegiance?: AllegianceSource chatBackend?: ChatBackend account?: AccountStore presence?: PresenceBackend @@ -110,6 +114,12 @@ export type ViewerProps = { export default function Viewer(props: ViewerProps) { const container = useRef(null) const {countryState, handleSetCountry} = useCountryStorage(props.sharedCountry) + const allegiance = useAllegiance(props.allegiance) + useEffect(() => { + const locked = lockOf(allegiance, Date.now()) + const held = locked && locked.countryId !== countryState.code ? Countries.get(locked.countryId) : undefined + if (held) handleSetCountry(held) + }, [allegiance, countryState.code, handleSetCountry]) const clickBudget = useClickBudget(props.clickBudgetSource, countryState.code) const connection = useConnection(props.connection) const sound = useSound() @@ -251,6 +261,7 @@ export default function Viewer(props: ViewerProps) { const board = { country: countryState, setCountry: handleSetCountry, + allegiance, leaderboard, tileDeltas, tilesCount, diff --git a/apps/frontend/src/app/viewer/globe.test.ts b/apps/frontend/src/app/viewer/globe.test.ts index 9c6b2d02e..ef78cfe49 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 {AllegianceLockedError, 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" @@ -43,6 +43,19 @@ describe("reportClickFailure", () => { expect(h.onVPNBlocked).not.toHaveBeenCalled() }) + it("plays a click refused for its flag as refused, with no dialog and no log", () => { + const h = handlers() + const logged = vi.spyOn(console, "error").mockImplementation(() => {}) + + expect(reportClickFailure(new AllegianceLockedError({countryId: "de", changeableAt: 1}), h)).toBe(true) + + expect(logged).not.toHaveBeenCalled() + expect(h.onRateLimited).not.toHaveBeenCalled() + expect(h.onVPNBlocked).not.toHaveBeenCalled() + expect(h.onSessionUnavailable).not.toHaveBeenCalled() + logged.mockRestore() + }) + it("logs anything else and raises no dialog", () => { const h = handlers() const logged = vi.spyOn(console, "error").mockImplementation(() => {}) diff --git a/apps/frontend/src/app/viewer/globe.ts b/apps/frontend/src/app/viewer/globe.ts index 572994737..7b774e74c 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 { + AllegianceLockedError, BombDrop, Bomber, GlobePoint, @@ -542,7 +543,7 @@ export async function createGlobe(options: GlobeOptions): Promise { playSound("click") shielder.placeShield(tile, country.code).catch((e) => { - if (lifetime.signal.aborted || e instanceof ShieldRefusedError) return + if (lifetime.signal.aborted || e instanceof ShieldRefusedError || e instanceof AllegianceLockedError) return reportClaimFailure(e, {onSessionUnavailable}) }) } @@ -1070,7 +1071,7 @@ export function reportClickFailure( if (error instanceof RateLimitedError) handlers.onRateLimited() else if (error instanceof VPNBlockedError) handlers.onVPNBlocked() else if (error instanceof SessionUnavailableError) handlers.onSessionUnavailable() - else { + else if (!(error instanceof AllegianceLockedError)) { console.error(error) return false } diff --git a/apps/frontend/src/app/viewer/useAllegiance.ts b/apps/frontend/src/app/viewer/useAllegiance.ts new file mode 100644 index 000000000..105e922d5 --- /dev/null +++ b/apps/frontend/src/app/viewer/useAllegiance.ts @@ -0,0 +1,11 @@ +import {useEffect, useState} from "react"; +import {AllegianceSource} from "../../backends/backend.ts"; +import {Allegiance} from "../../domain/allegiance.ts"; + +export function useAllegiance(source: AllegianceSource | undefined): Allegiance | undefined { + const [allegiance, setAllegiance] = useState() + + useEffect(() => source?.listenForAllegiance(setAllegiance), [source]) + + return allegiance +} diff --git a/apps/frontend/src/backends/backend.ts b/apps/frontend/src/backends/backend.ts index ca50bc0d7..f4c53eace 100644 --- a/apps/frontend/src/backends/backend.ts +++ b/apps/frontend/src/backends/backend.ts @@ -1,5 +1,6 @@ import {BonusReward, BonusRules, Charges, Switches} from "../domain/bonus.ts" import {QuizOffer, QuizOutcome, QuizQuestion} from "../domain/quiz.ts" +import {Allegiance} from "../domain/allegiance.ts" export interface TileClicker { clickTile(tileId: number, countryId: string, switches?: Switches): Promise @@ -137,6 +138,17 @@ export interface Shielder { placeShield(tileId: number, countryId: string): Promise } +export interface AllegianceSource { + listenForAllegiance(callback: (allegiance: Allegiance | undefined) => void): () => void +} + +export class AllegianceLockedError extends Error { + constructor(readonly allegiance: Allegiance, options?: ErrorOptions) { + super(`this player plays for ${allegiance.countryId} for now`, options) + this.name = "AllegianceLockedError" + } +} + export class ShieldRefusedError extends Error { constructor(options?: ErrorOptions) { super("the tile is not this player's, or holds all the shields it can", options) diff --git a/apps/frontend/src/backends/fakeBackend.ts b/apps/frontend/src/backends/fakeBackend.ts index 179bf806a..effa9ead6 100644 --- a/apps/frontend/src/backends/fakeBackend.ts +++ b/apps/frontend/src/backends/fakeBackend.ts @@ -1,4 +1,6 @@ import { + AllegianceLockedError, + AllegianceSource, BankFullError, BombDrop, Bomber, @@ -24,6 +26,7 @@ import { } from "./backend.ts"; import {ALL_OFF, BonusReward, BonusRules, Charges, NO_CHARGES, Switches} from "../domain/bonus.ts"; import {QuizOffer, QuizOutcome, QuizQuestion} from "../domain/quiz.ts"; +import {Allegiance, lockOf} from "../domain/allegiance.ts"; import {ClickBudget, ClickBudgetSource, ClickPrice, now as budgetNow, SharedBy} from "./clickBudget.ts"; import {SessionUnavailableError} from "./session.ts"; import {v4 as UUIDv4} from 'uuid'; @@ -36,6 +39,8 @@ const TILE_COUNT = 257_000 const CLICKS_PER_SECOND = 0.2 const CLICK_BURST = 60 +const FLAG_CHANGE_EVERY_MS = 60 * 60 * 1000 + const TOLL_STEPS = [ {share: 0.10, slowdown: 1.5}, {share: 0.20, slowdown: 2.5}, @@ -99,7 +104,7 @@ export type FakeBackendOptions = { landmasses?: () => Promise<{assignment: Uint16Array, count: number}> } -export class FakeBackend implements TileClicker, OwnershipsGetter, UpdatesListener, ClickBudgetSource, BonusListener, QuizMaster, Bomber, Refiller, Shielder { +export class FakeBackend implements TileClicker, OwnershipsGetter, UpdatesListener, ClickBudgetSource, BonusListener, QuizMaster, Bomber, Refiller, Shielder, AllegianceSource { private tileBindings: Map = new Map() private tileCounts: Map = new Map() private budgetCountry = "" @@ -115,6 +120,8 @@ export class FakeBackend implements TileClicker, OwnershipsGetter, UpdatesListen private fortresses: FakeFortresses | undefined private charges: Charges = NO_CHARGES + private allegiance: Allegiance | undefined + private readonly allegianceCallbacks = new Map void>() private positions: Promise | undefined private readonly tilePositions: (() => Promise) | undefined @@ -215,6 +222,7 @@ export class FakeBackend implements TileClicker, OwnershipsGetter, UpdatesListen if (switches.spread && switches.enclose) throw new Error("spread and enclose switched on together") if (this.sessionUnavailable) throw new SessionUnavailableError() if (this.vpnBlocked) throw new VPNBlockedError() + this.pledge(countryId) const allowed = this.allow(countryId) this.reportBudget() @@ -329,6 +337,32 @@ export class FakeBackend implements TileClicker, OwnershipsGetter, UpdatesListen this.budgetCallbacks.forEach(callback => callback(budget)) } + public unlockFlag(): void { + if (this.allegiance) this.holdAllegiance({countryId: this.allegiance.countryId}) + } + + public listenForAllegiance(callback: (allegiance: Allegiance | undefined) => void): () => void { + const id = UUIDv4() + this.allegianceCallbacks.set(id, callback) + callback(this.allegiance) + return () => this.allegianceCallbacks.delete(id) + } + + private pledge(countryId: string): void { + const held = this.allegiance + if (held?.countryId === countryId) return + + const locked = lockOf(held, Date.now()) + if (locked) throw new AllegianceLockedError(locked) + + this.holdAllegiance(held ? {countryId, changeableAt: Date.now() + FLAG_CHANGE_EVERY_MS} : {countryId}) + } + + private holdAllegiance(allegiance: Allegiance): void { + this.allegiance = allegiance + this.allegianceCallbacks.forEach(callback => callback(allegiance)) + } + public closeShapes(close: boolean): void { this.shapesClose = close } @@ -438,6 +472,7 @@ export class FakeBackend implements TileClicker, OwnershipsGetter, UpdatesListen public async placeShield(tileId: number, countryId: string): Promise { if (this.sessionUnavailable) throw new SessionUnavailableError() + this.pledge(countryId) if (this.charges.shields === 0) throw new BonusLostError() if (!this.botShield(tileId, countryId)) throw new ShieldRefusedError() diff --git a/apps/frontend/src/backends/planetBackend.test.ts b/apps/frontend/src/backends/planetBackend.test.ts index 3ff1b0e2f..13124049b 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 { + Allegiance as AllegianceMessage, BombDropped, BonusKind, BonusOffered, @@ -22,6 +23,7 @@ import { QuizOffered, ChargesHeld, ClickBudget as ClickBudgetMessage, + GetAllegianceResponse, GetBonusRulesResponse, GetMapResponse, GlobePoint, @@ -34,10 +36,11 @@ import { TilesSpread, TileUpdate, } from "../gen/grpc/planet/v1/planet_pb.ts" -import {BankFullError, BonusLostError, ShieldRefusedError, RateLimitedError, VPNBlockedError} from "./backend.ts" +import {AllegianceLockedError, BankFullError, 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" +import type {Allegiance} from "../domain/allegiance.ts" function fixedSession(token: string): SessionProvider { return {token: async () => token, held: () => token, identity: async () => token, heldIdentity: () => token, invalidate: () => {}} @@ -174,6 +177,7 @@ function bonusReads() { return { getBonusRules: vi.fn().mockResolvedValue(new GetBonusRulesResponse({blastRadius: 0.03, enclosureMaxTiles: 25, spreadClicks: 8, enclosures: 3})), getCharges: vi.fn().mockResolvedValue({charges: new ChargesHeld()}), + getAllegiance: vi.fn().mockResolvedValue(new GetAllegianceResponse()), } } @@ -1466,3 +1470,90 @@ describe("the refill", () => { backend.close() }) }) + +describe("PlanetBackend allegiance", () => { + const clientWith = (fields: Record) => + ({click: vi.fn(), getMap: vi.fn(), getBudget: noBudget(), ...bonusReads(), mapDensity: vi.fn(), listenForEvents: noEvents(), ...fields}) as never + + const held = (countryId: string, changeableAt?: number) => new GetAllegianceResponse({ + allegiance: new AllegianceMessage({countryId, changeableAtUnixMs: BigInt(changeableAt ?? 0)}), + }) + + const lockedTo = (countryId: string, changeableAt: number) => new ConnectError("too soon", Code.FailedPrecondition, undefined, + [new AllegianceMessage({countryId, changeableAtUnixMs: BigInt(changeableAt)})]) + + const follow = (backend: PlanetBackend) => { + const seen: (Allegiance | undefined)[] = [] + backend.listenForAllegiance((allegiance) => seen.push(allegiance)) + return seen + } + + it("reads the flag played for at load, as the identity the player holds", async () => { + const getAllegiance = vi.fn().mockResolvedValue(held("fr")) + const backend = new PlanetBackend(clientWith({getAllegiance}), 1_000, fixedSession("identity-1")) + const seen = follow(backend) + + await vi.waitFor(() => expect(seen.at(-1)).toEqual({countryId: "fr"})) + expect((getAllegiance.mock.calls[0][1] as {headers: Headers}).headers.get(SESSION_HEADER)).toBe("identity-1") + backend.close() + }) + + it("reads it again after a click goes out for another flag, and not for the same one", async () => { + const getAllegiance = vi.fn() + .mockResolvedValueOnce(held("fr")) + .mockResolvedValue(held("de", 1_700_000_000_000)) + const click = vi.fn().mockResolvedValue({}) + const backend = new PlanetBackend(clientWith({getAllegiance, click}), 1_000, fixedSession("session-1")) + const seen = follow(backend) + await vi.waitFor(() => expect(seen.at(-1)).toEqual({countryId: "fr"})) + + await backend.clickTile(7, "fr") + expect(getAllegiance).toHaveBeenCalledTimes(1) + + await backend.clickTile(8, "de") + await vi.waitFor(() => expect(seen.at(-1)).toEqual({countryId: "de", changeableAt: 1_700_000_000_000})) + backend.close() + }) + + it("turns a click refused for its flag into the flag held and until when", async () => { + const click = vi.fn().mockRejectedValue(lockedTo("de", 1_700_000_000_000)) + const backend = new PlanetBackend(clientWith({click}), 1_000, fixedSession("session-1")) + const seen = follow(backend) + + const clicking = backend.clickTile(7, "fr") + + await expect(clicking).rejects.toBeInstanceOf(AllegianceLockedError) + await expect(clicking).rejects.toMatchObject({allegiance: {countryId: "de", changeableAt: 1_700_000_000_000}}) + expect(seen.at(-1)).toEqual({countryId: "de", changeableAt: 1_700_000_000_000}) + backend.close() + }) + + it("turns a shield refused for its flag into the flag held, and gives the shield back", async () => { + const getCharges = vi.fn().mockResolvedValue({charges: new ChargesHeld({shields: 3})}) + const placeShield = vi.fn().mockRejectedValue(lockedTo("de", 1_700_000_000_000)) + const backend = new PlanetBackend(clientWith({getCharges, placeShield}), 1_000, fixedSession("session-1")) + const seen = follow(backend) + const charges: Charges[] = [] + backend.listenForBonuses({ + onOffered: () => {}, onTaken: () => {}, onEnclosed: () => {}, onSpread: () => {}, onRules: () => {}, + onCharges: (held) => charges.push(held), + }) + await vi.waitFor(() => expect(charges.at(-1)?.shields).toBe(3)) + + await expect(backend.placeShield(7, "fr")).rejects.toBeInstanceOf(AllegianceLockedError) + + expect(seen.at(-1)).toEqual({countryId: "de", changeableAt: 1_700_000_000_000}) + expect(charges.at(-1)?.shields).toBe(3) + backend.close() + }) + + it("holds no flag from a server that does not know one", async () => { + const getAllegiance = vi.fn().mockRejectedValue(new ConnectError("no such procedure", Code.Unimplemented)) + const backend = new PlanetBackend(clientWith({getAllegiance}), 1_000, fixedSession("identity-1")) + const seen = follow(backend) + + await vi.waitFor(() => expect(getAllegiance).toHaveBeenCalled()) + expect(seen.at(-1)).toBeUndefined() + backend.close() + }) +}) diff --git a/apps/frontend/src/backends/planetBackend.ts b/apps/frontend/src/backends/planetBackend.ts index 0537b2715..d00cb3892 100644 --- a/apps/frontend/src/backends/planetBackend.ts +++ b/apps/frontend/src/backends/planetBackend.ts @@ -1,4 +1,6 @@ import { + AllegianceLockedError, + AllegianceSource, BankFullError, BombDrop, Bomber, @@ -26,7 +28,9 @@ import { } from "./backend.ts"; import {ALL_OFF, BonusReward, BonusRules, Charges, NO_CHARGES, Switches} from "../domain/bonus.ts"; import {QuizOffer, QuizOutcome, QuizQuestion} from "../domain/quiz.ts"; +import {Allegiance} from "../domain/allegiance.ts"; import { + Allegiance as AllegianceMessage, BonusKind, ChargesHeld, ClickBudget as ClickBudgetMessage, @@ -54,7 +58,7 @@ export function newClickServiceClient(config: Config): PromiseClient void>() private readonly updateCallbacks = new Map void>() @@ -64,6 +68,7 @@ export class PlanetBackend implements TileClicker, OwnershipsGetter, UpdatesList private readonly budgetCallbacks = new Map void>() private readonly resumeCallbacks = new Map void>() private readonly fortificationCallbacks = new Map void>() + private readonly allegianceCallbacks = new Map void>() private readonly flushTimer: ReturnType private stopListening: () => void @@ -79,6 +84,8 @@ export class PlanetBackend implements TileClicker, OwnershipsGetter, UpdatesList private charges: Charges = NO_CHARGES + private allegiance: Allegiance | undefined + constructor( private client: PromiseClient, batchUpdateDurationMs: number, @@ -115,6 +122,7 @@ export class PlanetBackend implements TileClicker, OwnershipsGetter, UpdatesList this.budgetCallbacks.clear() this.resumeCallbacks.clear() this.fortificationCallbacks.clear() + this.allegianceCallbacks.clear() this.pendingUpdates = [] } @@ -158,8 +166,10 @@ export class PlanetBackend implements TileClicker, OwnershipsGetter, UpdatesList if (spread && this.charges.spreadClicksLeft > 0) { this.holdCharges({...this.charges, spreadClicksLeft: this.charges.spreadClicksLeft - 1}) } + if (this.allegiance?.countryId !== countryId) void this.readAllegiance(token) } catch (e) { this.anchorBudget(budgetDetailOf(e), countryId) + this.holdRefusal(e) throw e } } @@ -335,7 +345,7 @@ export class PlanetBackend implements TileClicker, OwnershipsGetter, UpdatesList this.followSession(token) return } - await this.readCharges(token) + await Promise.all([this.readCharges(token), this.readAllegiance(token)]) } private followSession(token: string | undefined): void { @@ -346,6 +356,7 @@ export class PlanetBackend implements TileClicker, OwnershipsGetter, UpdatesList this.stopListening = this.openEventStream() void this.readCharges(token) + void this.readAllegiance(token) } private async readRules(): Promise { @@ -387,6 +398,38 @@ export class PlanetBackend implements TileClicker, OwnershipsGetter, UpdatesList this.bonusCallbacks.forEach(handlers => handlers.onCharges(charges)) } + private async readAllegiance(token: string | undefined): Promise { + const headers = new Headers() + if (token) headers.set(SESSION_HEADER, token) + + try { + const res = await retrying(() => this.client.getAllegiance({}, {headers}), "getAllegiance") + if (token !== this.streamToken && token !== this.session.heldIdentity()) return + this.holdAllegiance(allegianceOfMessage(res.allegiance)) + } catch (e) { + if (e instanceof ConnectError && e.code === Code.Unimplemented) return + console.error("could not read the flag played for", e) + } + } + + private holdRefusal(e: unknown): void { + const locked = allegianceDetailOf(e) + if (locked) this.holdAllegiance(locked) + } + + private holdAllegiance(allegiance: Allegiance | undefined): void { + this.allegiance = allegiance + this.allegianceCallbacks.forEach(callback => callback(allegiance)) + } + + public listenForAllegiance(callback: (allegiance: Allegiance | undefined) => void): () => void { + const id = generateUUID() + this.allegianceCallbacks.set(id, callback) + callback(this.allegiance) + + return () => this.allegianceCallbacks.delete(id) + } + public listenForUpdates(callback: (update: Update) => void): () => void { const id = generateUUID() this.updateCallbacks.set(id, callback) @@ -626,9 +669,14 @@ export class PlanetBackend implements TileClicker, OwnershipsGetter, UpdatesList const headers = new Headers() if (sessionToken) headers.set(SESSION_HEADER, sessionToken) - const res = await this.client.placeShield({tileId, countryId}, {headers}) - this.followSession(sessionToken) - this.holdCharges(chargesOfMessage(res.charges)) + try { + const res = await this.client.placeShield({tileId, countryId}, {headers}) + this.followSession(sessionToken) + this.holdCharges(chargesOfMessage(res.charges)) + } catch (e) { + this.holdRefusal(e) + throw e + } } public listenForUpdatesBatch( @@ -762,6 +810,8 @@ export function asBonusError(e: unknown): unknown { } export function asShieldError(e: unknown): unknown { + const locked = allegianceDetailOf(e) + if (locked) return new AllegianceLockedError(locked, {cause: e}) if (e instanceof ConnectError && e.code === Code.FailedPrecondition) return new ShieldRefusedError({cause: e}) return asBonusError(e) @@ -788,6 +838,19 @@ export function priceOf(budget: ClickBudgetMessage): ClickPrice | undefined { } } +export function allegianceOfMessage(message: AllegianceMessage | undefined): Allegiance | undefined { + if (!message || message.countryId === "") return undefined + + const changeableAt = Number(message.changeableAtUnixMs) + return changeableAt === 0 ? {countryId: message.countryId} : {countryId: message.countryId, changeableAt} +} + +export function allegianceDetailOf(e: unknown): Allegiance | undefined { + if (!(e instanceof ConnectError) || e.code !== Code.FailedPrecondition) return undefined + + return allegianceOfMessage(e.findDetails(AllegianceMessage)[0]) +} + export function budgetDetailOf(e: unknown): ClickBudgetMessage | undefined { if (!(e instanceof ConnectError)) return undefined @@ -797,6 +860,9 @@ export function budgetDetailOf(e: unknown): ClickBudgetMessage | undefined { export function asClickError(e: unknown): unknown { if (e instanceof SessionUnavailableError) return e + const locked = allegianceDetailOf(e) + if (locked) return new AllegianceLockedError(locked, {cause: e}) + if (e instanceof ConnectError) { if (e.code === Code.ResourceExhausted) return new RateLimitedError({cause: e}) if (e.code === Code.PermissionDenied) return new VPNBlockedError({cause: e}) diff --git a/apps/frontend/src/domain/allegiance.test.ts b/apps/frontend/src/domain/allegiance.test.ts new file mode 100644 index 000000000..f2a8e3065 --- /dev/null +++ b/apps/frontend/src/domain/allegiance.test.ts @@ -0,0 +1,16 @@ +import {describe, expect, it} from "vitest" +import {lockOf} from "./allegiance.ts" + +describe("lockOf", () => { + const now = 1_700_000_000_000 + + it("holds a flag until the time the server said", () => { + expect(lockOf({countryId: "de", changeableAt: now + 1}, now)).toEqual({countryId: "de", changeableAt: now + 1}) + }) + + it("holds nothing once that time came, for a flag never changed, or with no flag", () => { + expect(lockOf({countryId: "de", changeableAt: now}, now)).toBeUndefined() + expect(lockOf({countryId: "fr"}, now)).toBeUndefined() + expect(lockOf(undefined, now)).toBeUndefined() + }) +}) diff --git a/apps/frontend/src/domain/allegiance.ts b/apps/frontend/src/domain/allegiance.ts new file mode 100644 index 000000000..3ab821b45 --- /dev/null +++ b/apps/frontend/src/domain/allegiance.ts @@ -0,0 +1,10 @@ +export type Allegiance = { + countryId: string + + // Milliseconds since the epoch; undefined for a flag never changed. + changeableAt?: number +} + +export function lockOf(allegiance: Allegiance | undefined, now: number): Allegiance | undefined { + return allegiance?.changeableAt !== undefined && allegiance.changeableAt > now ? allegiance : undefined +} diff --git a/apps/frontend/src/gen/grpc/planet/v1/planet_connect.ts b/apps/frontend/src/gen/grpc/planet/v1/planet_connect.ts index 3f97f773f..fb56b7e9f 100644 --- a/apps/frontend/src/gen/grpc/planet/v1/planet_connect.ts +++ b/apps/frontend/src/gen/grpc/planet/v1/planet_connect.ts @@ -3,7 +3,7 @@ /* eslint-disable */ // @ts-nocheck -import { AnswerQuizRequest, AnswerQuizResponse, ClaimBonusRequest, ClaimBonusResponse, ClickRequest, ClickResponse, DropBombRequest, DropBombResponse, GetBonusRulesRequest, GetBonusRulesResponse, GetBudgetRequest, GetBudgetResponse, GetChargesRequest, GetChargesResponse, GetFortressesRequest, GetFortressesResponse, GetMapRequest, GetMapResponse, ListenForEventsRequest, MapDensityRequest, MapDensityResponse, OpenQuizRequest, OpenQuizResponse, PlaceShieldRequest, PlaceShieldResponse, PlanetEvent, UseRefillRequest, UseRefillResponse } from "./planet_pb.js"; +import { AnswerQuizRequest, AnswerQuizResponse, ClaimBonusRequest, ClaimBonusResponse, ClickRequest, ClickResponse, DropBombRequest, DropBombResponse, GetAllegianceRequest, GetAllegianceResponse, GetBonusRulesRequest, GetBonusRulesResponse, GetBudgetRequest, GetBudgetResponse, GetChargesRequest, GetChargesResponse, GetFortressesRequest, GetFortressesResponse, GetMapRequest, GetMapResponse, ListenForEventsRequest, MapDensityRequest, MapDensityResponse, OpenQuizRequest, OpenQuizResponse, PlaceShieldRequest, PlaceShieldResponse, PlanetEvent, UseRefillRequest, UseRefillResponse } from "./planet_pb.js"; import { MethodIdempotency, MethodKind } from "@bufbuild/protobuf"; /** @@ -146,6 +146,17 @@ export const ClickService = { kind: MethodKind.Unary, idempotency: MethodIdempotency.NoSideEffects, }, + /** + * Not NO_SIDE_EFFECTS: the answer is about one caller now, and a cached one lies. + * + * @generated from rpc planet.v1.ClickService.GetAllegiance + */ + getAllegiance: { + name: "GetAllegiance", + I: GetAllegianceRequest, + O: GetAllegianceResponse, + kind: MethodKind.Unary, + }, } } as const; 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..572f2905c 100644 --- a/apps/frontend/src/gen/grpc/planet/v1/planet_pb.ts +++ b/apps/frontend/src/gen/grpc/planet/v1/planet_pb.ts @@ -82,6 +82,123 @@ proto3.util.setEnumType(BonusKind, "planet.v1.BonusKind", [ { no: 6, name: "BONUS_KIND_SHIELDS" }, ]); +/** + * @generated from message planet.v1.GetAllegianceRequest + */ +export class GetAllegianceRequest extends Message { + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "planet.v1.GetAllegianceRequest"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): GetAllegianceRequest { + return new GetAllegianceRequest().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): GetAllegianceRequest { + return new GetAllegianceRequest().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): GetAllegianceRequest { + return new GetAllegianceRequest().fromJsonString(jsonString, options); + } + + static equals(a: GetAllegianceRequest | PlainMessage | undefined, b: GetAllegianceRequest | PlainMessage | undefined): boolean { + return proto3.util.equals(GetAllegianceRequest, a, b); + } +} + +/** + * @generated from message planet.v1.GetAllegianceResponse + */ +export class GetAllegianceResponse extends Message { + /** + * Unset for an account that never acted for a flag. + * + * @generated from field: planet.v1.Allegiance allegiance = 1; + */ + allegiance?: Allegiance; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "planet.v1.GetAllegianceResponse"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "allegiance", kind: "message", T: Allegiance }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): GetAllegianceResponse { + return new GetAllegianceResponse().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): GetAllegianceResponse { + return new GetAllegianceResponse().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): GetAllegianceResponse { + return new GetAllegianceResponse().fromJsonString(jsonString, options); + } + + static equals(a: GetAllegianceResponse | PlainMessage | undefined, b: GetAllegianceResponse | PlainMessage | undefined): boolean { + return proto3.util.equals(GetAllegianceResponse, a, b); + } +} + +/** + * Also the detail of a click or a shield refused for another flag. + * + * @generated from message planet.v1.Allegiance + */ +export class Allegiance extends Message { + /** + * @generated from field: string country_id = 1; + */ + countryId = ""; + + /** + * When another flag may be played: 0 for a flag never changed. + * + * @generated from field: int64 changeable_at_unix_ms = 2; + */ + changeableAtUnixMs = protoInt64.zero; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "planet.v1.Allegiance"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "country_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 2, name: "changeable_at_unix_ms", kind: "scalar", T: 3 /* ScalarType.INT64 */ }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): Allegiance { + return new Allegiance().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): Allegiance { + return new Allegiance().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): Allegiance { + return new Allegiance().fromJsonString(jsonString, options); + } + + static equals(a: Allegiance | PlainMessage | undefined, b: Allegiance | PlainMessage | undefined): boolean { + return proto3.util.equals(Allegiance, a, b); + } +} + /** * @generated from message planet.v1.ClickBudget */ diff --git a/apps/frontend/src/main.tsx b/apps/frontend/src/main.tsx index 3df820386..a65073aa4 100644 --- a/apps/frontend/src/main.tsx +++ b/apps/frontend/src/main.tsx @@ -123,6 +123,7 @@ if (import.meta.env.DEV && import.meta.env.VITE_FAKE_BACKEND) { bomber={fake} refiller={fake} shielder={fake} + allegiance={fake} clickBudgetSource={fake} connection={connection} chatBackend={fakeChat} @@ -160,6 +161,7 @@ if (import.meta.env.DEV && import.meta.env.VITE_FAKE_BACKEND) { bomber={backend} refiller={backend} shielder={backend} + allegiance={backend} clickBudgetSource={backend} connection={connection} chatBackend={chatBackend} diff --git a/proto/planet/v1/planet.proto b/proto/planet/v1/planet.proto index c827562a6..c55df69d8 100644 --- a/proto/planet/v1/planet.proto +++ b/proto/planet/v1/planet.proto @@ -40,6 +40,24 @@ service ClickService { rpc GetFortresses(GetFortressesRequest) returns (GetFortressesResponse) { option idempotency_level = NO_SIDE_EFFECTS; } + + // Not NO_SIDE_EFFECTS: the answer is about one caller now, and a cached one lies. + rpc GetAllegiance(GetAllegianceRequest) returns (GetAllegianceResponse); +} + +message GetAllegianceRequest {} + +message GetAllegianceResponse { + // Unset for an account that never acted for a flag. + Allegiance allegiance = 1; +} + +// Also the detail of a click or a shield refused for another flag. +message Allegiance { + string country_id = 1; + + // When another flag may be played: 0 for a flag never changed. + int64 changeable_at_unix_ms = 2; } message ClickBudget { From 8586324fdead996110c3042aef0db4dabac0de36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Oester?= Date: Tue, 20 Oct 2026 23:40:13 +0200 Subject: [PATCH 2/3] chore(backoffice): generate GetAllegiance into the backoffice bindings Co-Authored-By: Claude Opus 5.5 --- .../src/gen/grpc/planet/v1/planet_connect.ts | 13 +- .../src/gen/grpc/planet/v1/planet_pb.ts | 117 ++++++++++++++++++ 2 files changed, 129 insertions(+), 1 deletion(-) diff --git a/apps/backoffice/src/gen/grpc/planet/v1/planet_connect.ts b/apps/backoffice/src/gen/grpc/planet/v1/planet_connect.ts index 3f97f773f..fb56b7e9f 100644 --- a/apps/backoffice/src/gen/grpc/planet/v1/planet_connect.ts +++ b/apps/backoffice/src/gen/grpc/planet/v1/planet_connect.ts @@ -3,7 +3,7 @@ /* eslint-disable */ // @ts-nocheck -import { AnswerQuizRequest, AnswerQuizResponse, ClaimBonusRequest, ClaimBonusResponse, ClickRequest, ClickResponse, DropBombRequest, DropBombResponse, GetBonusRulesRequest, GetBonusRulesResponse, GetBudgetRequest, GetBudgetResponse, GetChargesRequest, GetChargesResponse, GetFortressesRequest, GetFortressesResponse, GetMapRequest, GetMapResponse, ListenForEventsRequest, MapDensityRequest, MapDensityResponse, OpenQuizRequest, OpenQuizResponse, PlaceShieldRequest, PlaceShieldResponse, PlanetEvent, UseRefillRequest, UseRefillResponse } from "./planet_pb.js"; +import { AnswerQuizRequest, AnswerQuizResponse, ClaimBonusRequest, ClaimBonusResponse, ClickRequest, ClickResponse, DropBombRequest, DropBombResponse, GetAllegianceRequest, GetAllegianceResponse, GetBonusRulesRequest, GetBonusRulesResponse, GetBudgetRequest, GetBudgetResponse, GetChargesRequest, GetChargesResponse, GetFortressesRequest, GetFortressesResponse, GetMapRequest, GetMapResponse, ListenForEventsRequest, MapDensityRequest, MapDensityResponse, OpenQuizRequest, OpenQuizResponse, PlaceShieldRequest, PlaceShieldResponse, PlanetEvent, UseRefillRequest, UseRefillResponse } from "./planet_pb.js"; import { MethodIdempotency, MethodKind } from "@bufbuild/protobuf"; /** @@ -146,6 +146,17 @@ export const ClickService = { kind: MethodKind.Unary, idempotency: MethodIdempotency.NoSideEffects, }, + /** + * Not NO_SIDE_EFFECTS: the answer is about one caller now, and a cached one lies. + * + * @generated from rpc planet.v1.ClickService.GetAllegiance + */ + getAllegiance: { + name: "GetAllegiance", + I: GetAllegianceRequest, + O: GetAllegianceResponse, + kind: MethodKind.Unary, + }, } } as const; 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 17a2f73e2..1745757e6 100644 --- a/apps/backoffice/src/gen/grpc/planet/v1/planet_pb.ts +++ b/apps/backoffice/src/gen/grpc/planet/v1/planet_pb.ts @@ -82,6 +82,123 @@ proto3.util.setEnumType(BonusKind, "planet.v1.BonusKind", [ { no: 6, name: "BONUS_KIND_SHIELDS" }, ]); +/** + * @generated from message planet.v1.GetAllegianceRequest + */ +export class GetAllegianceRequest extends Message { + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "planet.v1.GetAllegianceRequest"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): GetAllegianceRequest { + return new GetAllegianceRequest().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): GetAllegianceRequest { + return new GetAllegianceRequest().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): GetAllegianceRequest { + return new GetAllegianceRequest().fromJsonString(jsonString, options); + } + + static equals(a: GetAllegianceRequest | PlainMessage | undefined, b: GetAllegianceRequest | PlainMessage | undefined): boolean { + return proto3.util.equals(GetAllegianceRequest, a, b); + } +} + +/** + * @generated from message planet.v1.GetAllegianceResponse + */ +export class GetAllegianceResponse extends Message { + /** + * Unset for an account that never acted for a flag. + * + * @generated from field: planet.v1.Allegiance allegiance = 1; + */ + allegiance?: Allegiance; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "planet.v1.GetAllegianceResponse"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "allegiance", kind: "message", T: Allegiance }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): GetAllegianceResponse { + return new GetAllegianceResponse().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): GetAllegianceResponse { + return new GetAllegianceResponse().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): GetAllegianceResponse { + return new GetAllegianceResponse().fromJsonString(jsonString, options); + } + + static equals(a: GetAllegianceResponse | PlainMessage | undefined, b: GetAllegianceResponse | PlainMessage | undefined): boolean { + return proto3.util.equals(GetAllegianceResponse, a, b); + } +} + +/** + * Also the detail of a click or a shield refused for another flag. + * + * @generated from message planet.v1.Allegiance + */ +export class Allegiance extends Message { + /** + * @generated from field: string country_id = 1; + */ + countryId = ""; + + /** + * When another flag may be played: 0 for a flag never changed. + * + * @generated from field: int64 changeable_at_unix_ms = 2; + */ + changeableAtUnixMs = protoInt64.zero; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "planet.v1.Allegiance"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "country_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 2, name: "changeable_at_unix_ms", kind: "scalar", T: 3 /* ScalarType.INT64 */ }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): Allegiance { + return new Allegiance().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): Allegiance { + return new Allegiance().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): Allegiance { + return new Allegiance().fromJsonString(jsonString, options); + } + + static equals(a: Allegiance | PlainMessage | undefined, b: Allegiance | PlainMessage | undefined): boolean { + return proto3.util.equals(Allegiance, a, b); + } +} + /** * @generated from message planet.v1.ClickBudget */ From c533b578b438352b9e8b97fa3cb68aa4a92bcb1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Oester?= Date: Fri, 23 Oct 2026 14:09:21 +0300 Subject: [PATCH 3/3] refactor(planet): an allegiance is read and written in postgres The flag an account plays for was held in memory and flushed to planet.allegiances every second, like the charges. Only the tile map needs that. Each pledge is now one transaction under an advisory lock on the account, GetAllegiance reads the table, and the memory storage, its flush runner and its config key are gone. The use cases are tested over a MemoryStore fake, and a contract suite runs on it and on postgres. Co-Authored-By: Claude Opus 5.5 --- apps/backend/CLAUDE.md | 29 ++-- apps/backend/internal/planet/config.go | 3 - .../planet/internal/allegiances/book.go | 8 +- .../planet/internal/allegiances/book_test.go | 64 ++++---- .../persistence_testing.go | 66 -------- .../inmemory_allegiance_storage/storage.go | 153 ------------------ .../storage_test.go | 86 ---------- .../postgres_allegiance_store/store.go | 84 +++++----- .../postgres_allegiance_store/store_test.go | 81 +++------- .../allegiances/store_contract_test.go | 19 +++ .../allegiances/store_contract_testing.go | 95 +++++++++++ .../internal/allegiances/store_testing.go | 52 ++++++ .../usecases/place_shield_usecase/usecase.go | 4 +- .../place_shield_usecase/usecase_test.go | 4 +- .../click_usecase/allegiance_click/usecase.go | 4 +- .../allegiance_click/usecase_test.go | 6 +- .../allegiance_query/query_memory.go | 34 ---- .../allegiance_query/query_memory_test.go | 44 ----- .../allegiance_query/query_postgres.go | 44 +++++ .../allegiance_query/query_postgres_test.go | 78 +++++++++ .../get_allegiance_handler/handler.go | 8 +- apps/backend/internal/planet/module.go | 14 +- 22 files changed, 426 insertions(+), 554 deletions(-) delete mode 100644 apps/backend/internal/planet/internal/allegiances/inmemory_allegiance_storage/persistence_testing.go delete mode 100644 apps/backend/internal/planet/internal/allegiances/inmemory_allegiance_storage/storage.go delete mode 100644 apps/backend/internal/planet/internal/allegiances/inmemory_allegiance_storage/storage_test.go create mode 100644 apps/backend/internal/planet/internal/allegiances/store_contract_test.go create mode 100644 apps/backend/internal/planet/internal/allegiances/store_contract_testing.go create mode 100644 apps/backend/internal/planet/internal/allegiances/store_testing.go delete mode 100644 apps/backend/internal/planet/internal/planetv1controller/get_allegiance_handler/allegiance_query/query_memory.go delete mode 100644 apps/backend/internal/planet/internal/planetv1controller/get_allegiance_handler/allegiance_query/query_memory_test.go create mode 100644 apps/backend/internal/planet/internal/planetv1controller/get_allegiance_handler/allegiance_query/query_postgres.go create mode 100644 apps/backend/internal/planet/internal/planetv1controller/get_allegiance_handler/allegiance_query/query_postgres_test.go diff --git a/apps/backend/CLAUDE.md b/apps/backend/CLAUDE.md index 47fb93b12..a5a069047 100644 --- a/apps/backend/CLAUDE.md +++ b/apps/backend/CLAUDE.md @@ -354,7 +354,6 @@ internal/planet/internal/ postgres_charge_store/ usecases/ allegiances/ the flag each account plays for, and when it last changed it - inmemory_allegiance_storage/ postgres_allegiance_store/ planetv1controller/ the edge: maps the wire to the use cases, nothing else get_takes_by_country_handler/takes_query/ InternalService's one read, straight from ledger_events @@ -882,8 +881,9 @@ player could paint for one country and defend another click to click. change is free too, and starts the wait: another flag within `changeEvery` (1h) of it is `allegiances.Refusal` (`ErrChangedTooSoon`), with the flag held and until when. The same flag again changes nothing. -- **`allegiances.Book.Pledge(payer, flag)`** applies it at the clock's time, under - the storage's lock, so two tabs clicking two flags at once cannot both change it. +- **`allegiances.Book.Pledge(ctx, payer, flag)`** applies it at the clock's time, in + the store's `Change`: one transaction under `pg_advisory_xact_lock` on the account, + so two tabs clicking two flags at once cannot both change it. A caller with no account, and a flag that is no country, are held to nothing: the click use case refuses the second. - **It is checked after the ban and before the throttle** (`allegiance_click`, @@ -894,15 +894,17 @@ player could paint for one country and defend another click to click. - **The refusal is `FailedPrecondition` with a `planet.v1.Allegiance` detail** (`allegiancemessage.Refusal`): the flag held and `changeable_at_unix_ms`. The client snaps back to that flag. -- **`ClickService/GetAllegiance`** answers the caller's (`allegiance_query`, from - memory), as `Identified`: unset for an account that never acted for a flag, and - `changeable_at_unix_ms` 0 for a flag never changed. Not `NO_SIDE_EFFECTS`. -- **Kept like the charges**: `inmemory_allegiance_storage` holds every account's - in memory, and flushes the ones that changed to `planet.allegiances` - (`account`, `flag`, `changed_at`, NULL for a first flag) every - `allegianceStorage.flushInterval`. **A row is never deleted**, not even with its - account: without the row the next flag is a first flag again, which would hand - out a free change. The row names nobody once the account is gone. +- **`ClickService/GetAllegiance`** answers the caller's (`allegiance_query`, straight + from postgres), as `Identified`: unset for an account that never acted for a flag, + and `changeable_at_unix_ms` 0 for a flag never changed. Not `NO_SIDE_EFFECTS`. +- **No memory copy: every click and every shield reads postgres**, and a change writes + it, in `planet.allegiances` (`account`, `flag`, `changed_at`, NULL for a first flag). + Only the tile map is kept in memory and flushed. A failing database fails the click, + as the error net's `internal`. `allegiances.StoreContractSuite` runs on postgres and + on `MemoryStore`, the fake the use cases are tested over (behind the tag). +- **A row is never deleted**, not even with its account: without the row the next flag + is a first flag again, which would hand out a free change. The row names nobody once + the account is gone. - **Guests are held per account**, like the bank: a private tab is a new guest and a new first flag. @@ -3356,8 +3358,6 @@ Both chains order them the same way: error mapping outermost, then the blocklist - **Size.** 1M takes are 83 MB of table and 31 MB of indexes (the primary key and the account). Production made ~116,000 takes in 72h at the start of October 2026, ~40,000 a day: ~15M rows and ~1.7 GB a year. Each take is written twice, once by the `COPY` and once when its scope is blanked, and autovacuum reuses the space. No partitioning yet. - **One pool for every runner.** `cppg.CloseAfter(db, logger, tilesStorage, takings, charges, deletions)` runs them together and closes the pool after the last flushes, and after the subscriber drained its buffer. -**The allegiances do too**, through `inmemory_allegiance_storage.Persistence` and `allegiances/postgres_allegiance_store`, on the same pool: one row per account in `planet.allegiances`. See [Allegiance](#allegiance-one-change-of-flag-an-hour). - **The charges follow it too**, through `inmemory_charge_storage.Persistence` and `bonuses/postgres_charge_store`, on the same pool: one row per account in `planet.charges`, written every `chargeStorage.flushInterval`. See [Charges](#charges-refill-bomb-enclose-spread-shields). The antibot's evidence is in postgres too, in the `antibot` schema, the same way, and its bans are read and written there on every call — see [What survives a restart](#what-survives-a-restart). The player module's profiles and stats are in postgres too, in the `player` schema, but with no memory copy: each call reads or writes the table — see [Player](#player-internalplayer). The seasons module's standings are kept the same way, in the `seasons` schema — see [Seasons](#seasons-internalseasons). So are the support module's notes, in the `support` schema — see [Support](#support-internalsupport). @@ -3950,7 +3950,6 @@ There is no struct-tag validation and therefore no validator dependency — a ho - `bonus.enclose.held`, `bonus.enclose.maxPerBox`, `bonus.enclose.maxTiles` — the most enclosures held (3), the most one box adds (3; it draws 1 to that), and the most tiles one shape may take (25) - `bonus.shield.held`, `bonus.shield.minPerBox`, `bonus.shield.maxPerBox`, `bonus.shield.perTile` — the most shields held (30), the fewest and the most one box adds (5 and 20; it draws evenly between them), and the most one tile holds (10) - `chargeStorage.flushInterval` — how often the charges that changed are written to postgres (default 1s); also flushed on shutdown -- `allegianceStorage.flushInterval` — how often the flags 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.bans.enforce` — off judges, logs and counts without refusing; the mode to deploy in diff --git a/apps/backend/internal/planet/config.go b/apps/backend/internal/planet/config.go index 56e84d207..a2eb7d10d 100644 --- a/apps/backend/internal/planet/config.go +++ b/apps/backend/internal/planet/config.go @@ -5,7 +5,6 @@ import ( "fmt" "github.com/raphoester/clickplanet.lol-backend/internal/antibot" - "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/allegiances/inmemory_allegiance_storage" "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/bonuses" "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/bonuses/inmemory_charge_storage" "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/clicks" @@ -28,8 +27,6 @@ type Config struct { ChargeStorage inmemory_charge_storage.Config - AllegianceStorage inmemory_allegiance_storage.Config - Ledger ledger.Config LedgerStorage inmemory_ledger_storage.Config diff --git a/apps/backend/internal/planet/internal/allegiances/book.go b/apps/backend/internal/planet/internal/allegiances/book.go index 808027207..e0c2eb91f 100644 --- a/apps/backend/internal/planet/internal/allegiances/book.go +++ b/apps/backend/internal/planet/internal/allegiances/book.go @@ -1,6 +1,7 @@ package allegiances import ( + "context" "fmt" "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/clicks" @@ -8,7 +9,8 @@ import ( ) type Store interface { - Change(account Account, change func(Allegiance) (Allegiance, error)) error + // Change runs under a lock on the account, and keeps what it answers when that differs from what it was handed. + Change(ctx context.Context, account Account, change func(Allegiance) (Allegiance, error)) error } type CountryChecker interface { @@ -25,14 +27,14 @@ type Book struct { clock cptime.Clock } -func (b Book) Pledge(payer clicks.Payer, flag string) error { +func (b Book) Pledge(ctx context.Context, payer clicks.Payer, flag string) error { account := AccountOf(payer) if account == NoAccount || !b.countries.CheckCountry(flag) { return nil } at := b.clock.Now() - if err := b.store.Change(account, func(allegiance Allegiance) (Allegiance, error) { + if err := b.store.Change(ctx, account, func(allegiance Allegiance) (Allegiance, error) { return allegiance.Pledged(flag, at) }); err != nil { return fmt.Errorf("account %s may not play for %q: %w", account, flag, err) diff --git a/apps/backend/internal/planet/internal/allegiances/book_test.go b/apps/backend/internal/planet/internal/allegiances/book_test.go index 62037221b..57ed0591c 100644 --- a/apps/backend/internal/planet/internal/allegiances/book_test.go +++ b/apps/backend/internal/planet/internal/allegiances/book_test.go @@ -1,7 +1,7 @@ package allegiances_test import ( - "log/slog" + "errors" "testing" "time" @@ -9,7 +9,6 @@ import ( "github.com/stretchr/testify/require" "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/allegiances" - "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/allegiances/inmemory_allegiance_storage" "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/clicks" "github.com/raphoester/clickplanet.lol-backend/internal/shared/cpcountries" "github.com/raphoester/clickplanet.lol-backend/internal/shared/cptime" @@ -20,60 +19,65 @@ const ( bob = "01926c6e-7a4b-7c3d-8e9f-0a1b2c3d4e60" ) -func book(t *testing.T) (allegiances.Book, *inmemory_allegiance_storage.Storage, *cptime.FixedClock) { - t.Helper() - - storage := inmemory_allegiance_storage.New(inmemory_allegiance_storage.Config{}, - inmemory_allegiance_storage.NewMemoryPersistence(), slog.New(slog.DiscardHandler)) +func book() (allegiances.Book, *allegiances.MemoryStore, *cptime.FixedClock) { + store := allegiances.NewMemoryStore() clock := cptime.NewFixedClock(at) - return allegiances.NewBook(storage, cpcountries.New(), clock), storage, clock + return allegiances.NewBook(store, cpcountries.New(), clock), store, clock } func TestAnAccountIsHeldToItsFlagForAnHourAfterAChange(t *testing.T) { - b, storage, clock := book(t) + b, store, clock := book() payer := clicks.Payer{Account: alice} - require.NoError(t, b.Pledge(payer, "fr")) - require.NoError(t, b.Pledge(payer, "de")) + require.NoError(t, b.Pledge(t.Context(), payer, "fr")) + require.NoError(t, b.Pledge(t.Context(), payer, "de")) clock.Advance(59 * time.Minute) - err := b.Pledge(payer, "fr") + err := b.Pledge(t.Context(), payer, "fr") require.ErrorIs(t, err, allegiances.ErrChangedTooSoon) - assert.Equal(t, allegiances.Snapshot{Flag: "de", ChangedAt: at}, storage.Snapshot(alice)) + assert.Equal(t, allegiances.Snapshot{Flag: "de", ChangedAt: at}, store.Kept(alice)) clock.Advance(time.Minute) - require.NoError(t, b.Pledge(payer, "fr")) - assert.Equal(t, allegiances.Snapshot{Flag: "fr", ChangedAt: at.Add(time.Hour)}, storage.Snapshot(alice)) + require.NoError(t, b.Pledge(t.Context(), payer, "fr")) + assert.Equal(t, allegiances.Snapshot{Flag: "fr", ChangedAt: at.Add(time.Hour)}, store.Kept(alice)) } func TestEachAccountHasItsOwnFlag(t *testing.T) { - b, storage, _ := book(t) + b, store, _ := book() - require.NoError(t, b.Pledge(clicks.Payer{Account: alice, Scope: "1.2.3.4"}, "fr")) - require.NoError(t, b.Pledge(clicks.Payer{Account: alice, Scope: "1.2.3.4"}, "de")) - require.NoError(t, b.Pledge(clicks.Payer{Account: bob, Scope: "1.2.3.4"}, "fr")) + require.NoError(t, b.Pledge(t.Context(), clicks.Payer{Account: alice, Scope: "1.2.3.4"}, "fr")) + require.NoError(t, b.Pledge(t.Context(), clicks.Payer{Account: alice, Scope: "1.2.3.4"}, "de")) + require.NoError(t, b.Pledge(t.Context(), clicks.Payer{Account: bob, Scope: "1.2.3.4"}, "fr")) - assert.Equal(t, "de", storage.Snapshot(alice).Flag) - assert.Equal(t, "fr", storage.Snapshot(bob).Flag) + assert.Equal(t, "de", store.Kept(alice).Flag) + assert.Equal(t, "fr", store.Kept(bob).Flag) } func TestACallerWithNoAccountIsHeldToNothing(t *testing.T) { - b, storage, _ := book(t) + b, store, _ := book() - require.NoError(t, b.Pledge(clicks.Payer{Scope: "1.2.3.4"}, "fr")) - require.NoError(t, b.Pledge(clicks.Payer{Scope: "1.2.3.4"}, "de")) + require.NoError(t, b.Pledge(t.Context(), clicks.Payer{Scope: "1.2.3.4"}, "fr")) + require.NoError(t, b.Pledge(t.Context(), clicks.Payer{Scope: "1.2.3.4"}, "de")) - assert.Equal(t, allegiances.Snapshot{}, storage.Snapshot(allegiances.NoAccount)) + assert.Equal(t, allegiances.Snapshot{}, store.Kept(allegiances.NoAccount)) } func TestAFlagThatIsNoCountryIsNotKept(t *testing.T) { - b, storage, _ := book(t) + b, store, _ := book() payer := clicks.Payer{Account: alice} - require.NoError(t, b.Pledge(payer, "fr")) - require.NoError(t, b.Pledge(payer, "de")) + require.NoError(t, b.Pledge(t.Context(), payer, "fr")) + require.NoError(t, b.Pledge(t.Context(), payer, "de")) + + require.NoError(t, b.Pledge(t.Context(), payer, "zz")) + + assert.Equal(t, "de", store.Kept(alice).Flag) +} - require.NoError(t, b.Pledge(payer, "zz")) +func TestAStoreThatFailsFailsThePledge(t *testing.T) { + b, store, _ := book() + down := errors.New("postgres is down") + store.FailWith(down) - assert.Equal(t, "de", storage.Snapshot(alice).Flag) + assert.ErrorIs(t, b.Pledge(t.Context(), clicks.Payer{Account: alice}, "fr"), down) } diff --git a/apps/backend/internal/planet/internal/allegiances/inmemory_allegiance_storage/persistence_testing.go b/apps/backend/internal/planet/internal/allegiances/inmemory_allegiance_storage/persistence_testing.go deleted file mode 100644 index 72cded50d..000000000 --- a/apps/backend/internal/planet/internal/allegiances/inmemory_allegiance_storage/persistence_testing.go +++ /dev/null @@ -1,66 +0,0 @@ -//go:build testing - -package inmemory_allegiance_storage - -import ( - "context" - "maps" - "sync" - - "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/allegiances" -) - -type MemoryPersistence struct { - mu sync.Mutex - kept map[allegiances.Account]allegiances.Snapshot - failing error -} - -func NewMemoryPersistence() *MemoryPersistence { - return &MemoryPersistence{kept: map[allegiances.Account]allegiances.Snapshot{}} -} - -func (m *MemoryPersistence) Load(_ context.Context, visit func(allegiances.Account, allegiances.Snapshot)) error { - m.mu.Lock() - defer m.mu.Unlock() - - if m.failing != nil { - return m.failing - } - for account, kept := range m.kept { - visit(account, kept) - } - return nil -} - -func (m *MemoryPersistence) Save(_ context.Context, kept map[allegiances.Account]allegiances.Snapshot) error { - m.mu.Lock() - defer m.mu.Unlock() - - if m.failing != nil { - return m.failing - } - maps.Copy(m.kept, kept) - return nil -} - -func (m *MemoryPersistence) Stored() map[allegiances.Account]allegiances.Snapshot { - m.mu.Lock() - defer m.mu.Unlock() - - return maps.Clone(m.kept) -} - -func (m *MemoryPersistence) FailWith(err error) { - m.mu.Lock() - defer m.mu.Unlock() - - m.failing = err -} - -func (m *MemoryPersistence) Heal() { - m.mu.Lock() - defer m.mu.Unlock() - - m.failing = nil -} diff --git a/apps/backend/internal/planet/internal/allegiances/inmemory_allegiance_storage/storage.go b/apps/backend/internal/planet/internal/allegiances/inmemory_allegiance_storage/storage.go deleted file mode 100644 index ddb4d8c2a..000000000 --- a/apps/backend/internal/planet/internal/allegiances/inmemory_allegiance_storage/storage.go +++ /dev/null @@ -1,153 +0,0 @@ -package inmemory_allegiance_storage - -import ( - "context" - "fmt" - "log/slog" - "sync" - "time" - - "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/allegiances" - "github.com/raphoester/clickplanet.lol-backend/internal/shared/cpcolls" -) - -type Persistence interface { - Load(ctx context.Context, visit func(account allegiances.Account, kept allegiances.Snapshot)) error - Save(ctx context.Context, kept map[allegiances.Account]allegiances.Snapshot) error -} - -type Config struct { - FlushInterval time.Duration -} - -const ( - defaultFlushInterval = time.Second - flushTimeout = 10 * time.Second -) - -func (c Config) withDefaults() Config { - if c.FlushInterval <= 0 { - c.FlushInterval = defaultFlushInterval - } - - return c -} - -func New(config Config, persistence Persistence, logger *slog.Logger) *Storage { - return &Storage{ - config: config.withDefaults(), - persistence: persistence, - logger: logger, - kept: make(map[allegiances.Account]allegiances.Allegiance), - dirty: cpcolls.NewSet[allegiances.Account](), - } -} - -type Storage struct { - config Config - persistence Persistence - logger *slog.Logger - - mu sync.Mutex - kept map[allegiances.Account]allegiances.Allegiance - dirty *cpcolls.Set[allegiances.Account] - - flushMu sync.Mutex -} - -var _ allegiances.Store = (*Storage)(nil) - -func (s *Storage) Load(ctx context.Context) error { - s.mu.Lock() - defer s.mu.Unlock() - - err := s.persistence.Load(ctx, func(account allegiances.Account, kept allegiances.Snapshot) { - s.kept[account] = kept.Restore() - }) - if err != nil { - return fmt.Errorf("failed to read the stored allegiances: %w", err) - } - - s.logger.Info("loaded the allegiances", slog.Int("accounts", len(s.kept))) - - return nil -} - -func (s *Storage) Change(account allegiances.Account, change func(allegiances.Allegiance) (allegiances.Allegiance, error)) error { - s.mu.Lock() - defer s.mu.Unlock() - - before := s.kept[account] - after, err := change(before) - if err != nil { - return err - } - - if after.TakeSnapshot() != before.TakeSnapshot() { - s.kept[account] = after - s.dirty.Add(account) - } - - return nil -} - -func (s *Storage) Snapshot(account allegiances.Account) allegiances.Snapshot { - s.mu.Lock() - defer s.mu.Unlock() - - return s.kept[account].TakeSnapshot() -} - -func (s *Storage) Name() string { return "allegiance-storage" } - -func (s *Storage) Run(ctx context.Context) { - ticker := time.NewTicker(s.config.FlushInterval) - defer ticker.Stop() - - for { - select { - case <-ticker.C: - s.flushOrLog(ctx) - case <-ctx.Done(): - s.flushOrLog(context.WithoutCancel(ctx)) - return - } - } -} - -func (s *Storage) flushOrLog(ctx context.Context) { - ctx, cancel := context.WithTimeout(ctx, flushTimeout) - defer cancel() - - if err := s.Flush(ctx); err != nil { - s.logger.Error("failed to flush the allegiances, retrying next tick", slog.Any("error", err)) - } -} - -func (s *Storage) Flush(ctx context.Context) error { - s.flushMu.Lock() - defer s.flushMu.Unlock() - - s.mu.Lock() - if s.dirty.Empty() { - s.mu.Unlock() - return nil - } - - changed := make(map[allegiances.Account]allegiances.Snapshot, s.dirty.Len()) - s.dirty.ForEach(func(account allegiances.Account) { changed[account] = s.kept[account].TakeSnapshot() }) - s.dirty = cpcolls.NewSet[allegiances.Account]() - s.mu.Unlock() - - if err := s.persistence.Save(ctx, changed); err != nil { - s.mu.Lock() - for account := range changed { - s.dirty.Add(account) - } - s.mu.Unlock() - - return fmt.Errorf("failed to save %d allegiances: %w", len(changed), err) - } - - return nil -} diff --git a/apps/backend/internal/planet/internal/allegiances/inmemory_allegiance_storage/storage_test.go b/apps/backend/internal/planet/internal/allegiances/inmemory_allegiance_storage/storage_test.go deleted file mode 100644 index 28438e28b..000000000 --- a/apps/backend/internal/planet/internal/allegiances/inmemory_allegiance_storage/storage_test.go +++ /dev/null @@ -1,86 +0,0 @@ -package inmemory_allegiance_storage_test - -import ( - "errors" - "log/slog" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/allegiances" - "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/allegiances/inmemory_allegiance_storage" -) - -const ( - alice allegiances.Account = "01926c6e-7a4b-7c3d-8e9f-0a1b2c3d4e5f" - bob allegiances.Account = "01926c6e-7a4b-7c3d-8e9f-0a1b2c3d4e60" -) - -var at = time.Date(2026, 10, 17, 12, 0, 0, 0, time.UTC) - -func storageOver(persistence *inmemory_allegiance_storage.MemoryPersistence) *inmemory_allegiance_storage.Storage { - return inmemory_allegiance_storage.New(inmemory_allegiance_storage.Config{}, persistence, slog.New(slog.DiscardHandler)) -} - -func pledge(flag string, when time.Time) func(allegiances.Allegiance) (allegiances.Allegiance, error) { - return func(allegiance allegiances.Allegiance) (allegiances.Allegiance, error) { - return allegiance.Pledged(flag, when) - } -} - -func TestABootLoadsWhatWasKept(t *testing.T) { - persistence := inmemory_allegiance_storage.NewMemoryPersistence() - require.NoError(t, persistence.Save(t.Context(), map[allegiances.Account]allegiances.Snapshot{ - alice: {Flag: "de", ChangedAt: at}, - })) - storage := storageOver(persistence) - - require.NoError(t, storage.Load(t.Context())) - - assert.Equal(t, allegiances.Snapshot{Flag: "de", ChangedAt: at}, storage.Snapshot(alice)) - require.ErrorIs(t, storage.Change(alice, pledge("fr", at.Add(time.Minute))), allegiances.ErrChangedTooSoon) -} - -func TestAFailedLoadRefusesTheBoot(t *testing.T) { - persistence := inmemory_allegiance_storage.NewMemoryPersistence() - persistence.FailWith(errors.New("postgres is down")) - - assert.Error(t, storageOver(persistence).Load(t.Context())) -} - -func TestAFlushWritesWhatChangedSinceTheLast(t *testing.T) { - persistence := inmemory_allegiance_storage.NewMemoryPersistence() - storage := storageOver(persistence) - require.NoError(t, storage.Change(alice, pledge("fr", at))) - require.NoError(t, storage.Change(bob, pledge("de", at))) - - require.NoError(t, storage.Flush(t.Context())) - - assert.Equal(t, map[allegiances.Account]allegiances.Snapshot{alice: {Flag: "fr"}, bob: {Flag: "de"}}, persistence.Stored()) -} - -func TestARefusedChangeKeepsTheAllegiance(t *testing.T) { - storage := storageOver(inmemory_allegiance_storage.NewMemoryPersistence()) - require.NoError(t, storage.Change(alice, pledge("fr", at))) - require.NoError(t, storage.Change(alice, pledge("de", at))) - - require.Error(t, storage.Change(alice, pledge("fr", at.Add(time.Minute)))) - - assert.Equal(t, allegiances.Snapshot{Flag: "de", ChangedAt: at}, storage.Snapshot(alice)) -} - -func TestAFailedFlushIsWrittenOnTheNext(t *testing.T) { - persistence := inmemory_allegiance_storage.NewMemoryPersistence() - storage := storageOver(persistence) - require.NoError(t, storage.Change(alice, pledge("fr", at))) - - persistence.FailWith(errors.New("postgres is down")) - require.Error(t, storage.Flush(t.Context())) - - persistence.Heal() - require.NoError(t, storage.Flush(t.Context())) - - assert.Equal(t, map[allegiances.Account]allegiances.Snapshot{alice: {Flag: "fr"}}, persistence.Stored()) -} diff --git a/apps/backend/internal/planet/internal/allegiances/postgres_allegiance_store/store.go b/apps/backend/internal/planet/internal/allegiances/postgres_allegiance_store/store.go index bd593c214..48d0bbe24 100644 --- a/apps/backend/internal/planet/internal/allegiances/postgres_allegiance_store/store.go +++ b/apps/backend/internal/planet/internal/allegiances/postgres_allegiance_store/store.go @@ -3,73 +3,83 @@ package postgres_allegiance_store import ( "context" "database/sql" + "errors" "fmt" - "maps" - "slices" "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/allegiances" - "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/allegiances/inmemory_allegiance_storage" "github.com/raphoester/clickplanet.lol-backend/internal/shared/cppg" ) -func New(db cppg.QuerierBeginner) *Store { +const allegianceLock = 0x616c6c65 + +func New(db cppg.Beginner) *Store { return &Store{db: db} } type Store struct { - db cppg.QuerierBeginner + db cppg.Beginner } -var _ inmemory_allegiance_storage.Persistence = (*Store)(nil) +var _ allegiances.Store = (*Store)(nil) -func (s *Store) Load(ctx context.Context, visit func(allegiances.Account, allegiances.Snapshot)) error { - rows, err := s.db.QueryContext(ctx, `SELECT account::text, flag, changed_at FROM allegiances`) +func (s *Store) Change( + ctx context.Context, + account allegiances.Account, + change func(allegiances.Allegiance) (allegiances.Allegiance, error), +) (err error) { + tx, err := s.db.BeginTx(ctx, &sql.TxOptions{}) if err != nil { - return fmt.Errorf("failed to read the allegiances: %w", err) + return fmt.Errorf("failed to begin changing the allegiance: %w", err) } - defer func() { _ = rows.Close() }() - - for rows.Next() { - var ( - account string - flag string - changedAt sql.NullTime - ) - if err := rows.Scan(&account, &flag, &changedAt); err != nil { - return fmt.Errorf("failed to scan an allegiance: %w", err) + defer func() { + if err != nil { + _ = tx.Rollback() } - visit(allegiances.Account(account), allegiances.Snapshot{Flag: flag, ChangedAt: changedAt.Time}) - } + }() - if err := rows.Err(); err != nil { - return fmt.Errorf("failed to read the allegiances: %w", err) + if _, err := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock($1, hashtext($2))`, allegianceLock, string(account)); err != nil { + return fmt.Errorf("failed to lock the allegiance: %w", err) } - return nil -} - -func (s *Store) Save(ctx context.Context, kept map[allegiances.Account]allegiances.Snapshot) error { - tx, err := s.db.BeginTx(ctx, &sql.TxOptions{}) + before, err := keptOf(ctx, tx, account) if err != nil { - return fmt.Errorf("failed to save the allegiances: %w", err) + return err } - defer func() { _ = tx.Rollback() }() - for _, account := range slices.Sorted(maps.Keys(kept)) { - allegiance := kept[account] - changedAt := sql.NullTime{Time: allegiance.ChangedAt.UTC(), Valid: !allegiance.ChangedAt.IsZero()} + after, err := change(before.Restore()) + if err != nil { + return err + } + if kept := after.TakeSnapshot(); kept != before { + changedAt := sql.NullTime{Time: kept.ChangedAt.UTC(), Valid: !kept.ChangedAt.IsZero()} if _, err := tx.ExecContext(ctx, ` INSERT INTO allegiances (account, flag, changed_at) VALUES ($1, $2, $3) ON CONFLICT (account) DO UPDATE SET flag = EXCLUDED.flag, changed_at = EXCLUDED.changed_at - `, string(account), allegiance.Flag, changedAt); err != nil { - return fmt.Errorf("failed to write the allegiance of %s: %w", account, err) + `, string(account), kept.Flag, changedAt); err != nil { + return fmt.Errorf("failed to write the allegiance: %w", err) } } if err := tx.Commit(); err != nil { - return fmt.Errorf("failed to commit the allegiances: %w", err) + return fmt.Errorf("failed to commit the allegiance: %w", err) } - return nil } + +func keptOf(ctx context.Context, tx *sql.Tx, account allegiances.Account) (allegiances.Snapshot, error) { + var ( + flag string + changedAt sql.NullTime + ) + err := tx.QueryRowContext(ctx, `SELECT flag, changed_at FROM allegiances WHERE account = $1`, string(account)). + Scan(&flag, &changedAt) + switch { + case errors.Is(err, sql.ErrNoRows): + return allegiances.Snapshot{}, nil + case err != nil: + return allegiances.Snapshot{}, fmt.Errorf("failed to read the allegiance: %w", err) + } + + return allegiances.Snapshot{Flag: flag, ChangedAt: changedAt.Time}, nil +} diff --git a/apps/backend/internal/planet/internal/allegiances/postgres_allegiance_store/store_test.go b/apps/backend/internal/planet/internal/allegiances/postgres_allegiance_store/store_test.go index 3d637e2d0..ec516dee5 100644 --- a/apps/backend/internal/planet/internal/allegiances/postgres_allegiance_store/store_test.go +++ b/apps/backend/internal/planet/internal/allegiances/postgres_allegiance_store/store_test.go @@ -1,9 +1,10 @@ package postgres_allegiance_store_test import ( + "fmt" "testing" - "time" + "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/allegiances" @@ -12,67 +13,31 @@ import ( "github.com/raphoester/clickplanet.lol-backend/internal/shared/cppg" ) -func TestRunSuite(t *testing.T) { - suite.Run(t, new(testSuite)) -} - -type testSuite struct { - suite.Suite - db *cppg.Postgres - store *postgres_allegiance_store.Store -} - -func (s *testSuite) SetupSuite() { - s.db = cppg.StartTestServer(s.T()).OpenSchema(s.T(), "planet", migrations.FS) - s.store = postgres_allegiance_store.New(s.db) -} +func TestThePostgresStoreKeepsTheContract(t *testing.T) { + db := cppg.StartTestServer(t).OpenSchema(t, "planet", migrations.FS) -func (s *testSuite) SetupTest() { - s.Require().NoError(s.db.Purge(s.T().Context())) + suite.Run(t, &allegiances.StoreContractSuite{ + NewStore: func() allegiances.Store { + require.NoError(t, db.Purge(t.Context())) + return postgres_allegiance_store.New(db) + }, + Account: func(n int) allegiances.Account { + return allegiances.Account(fmt.Sprintf("01926c6e-7a4b-7c3d-8e9f-0a1b2c3d4e%02d", n)) + }, + }) } -const ( - alice allegiances.Account = "01926c6e-7a4b-7c3d-8e9f-0a1b2c3d4e5f" - bob allegiances.Account = "01926c6e-7a4b-7c3d-8e9f-0a1b2c3d4e60" -) - -var at = time.Date(2026, 10, 17, 12, 0, 0, 0, time.UTC) +func TestAFirstFlagIsKeptWithNoTime(t *testing.T) { + db := cppg.StartTestServer(t).OpenSchema(t, "planet", migrations.FS) + store := postgres_allegiance_store.New(db) + const alice allegiances.Account = "01926c6e-7a4b-7c3d-8e9f-0a1b2c3d4e5f" -func (s *testSuite) load() map[allegiances.Account]allegiances.Snapshot { - kept := map[allegiances.Account]allegiances.Snapshot{} - s.Require().NoError(s.store.Load(s.T().Context(), func(account allegiances.Account, allegiance allegiances.Snapshot) { - kept[account] = allegiance + require.NoError(t, store.Change(t.Context(), alice, func(allegiances.Allegiance) (allegiances.Allegiance, error) { + return allegiances.Snapshot{Flag: "fr"}.Restore(), nil })) - return kept -} - -func (s *testSuite) TestAnEmptyStoreLoadsNothing() { - s.Empty(s.load()) -} - -func (s *testSuite) TestSaveThenLoadEveryAllegiance() { - kept := map[allegiances.Account]allegiances.Snapshot{ - alice: {Flag: "fr"}, - bob: {Flag: "de", ChangedAt: at}, - } - - s.Require().NoError(s.store.Save(s.T().Context(), kept)) - - loaded := s.load() - s.Equal("fr", loaded[alice].Flag) - s.True(loaded[alice].ChangedAt.IsZero(), "a first flag keeps no time") - s.Equal("de", loaded[bob].Flag) - s.True(at.Equal(loaded[bob].ChangedAt)) -} - -func (s *testSuite) TestASecondSaveReplacesTheAllegiance() { - ctx := s.T().Context() - s.Require().NoError(s.store.Save(ctx, map[allegiances.Account]allegiances.Snapshot{alice: {Flag: "fr"}})) - - s.Require().NoError(s.store.Save(ctx, map[allegiances.Account]allegiances.Snapshot{alice: {Flag: "de", ChangedAt: at}})) - loaded := s.load() - s.Len(loaded, 1) - s.Equal("de", loaded[alice].Flag) - s.True(at.Equal(loaded[alice].ChangedAt)) + var changedAt *string + require.NoError(t, db.QueryRowContext(t.Context(), `SELECT changed_at::text FROM allegiances WHERE account = $1`, + string(alice)).Scan(&changedAt)) + require.Nil(t, changedAt) } diff --git a/apps/backend/internal/planet/internal/allegiances/store_contract_test.go b/apps/backend/internal/planet/internal/allegiances/store_contract_test.go new file mode 100644 index 000000000..fb2f71c9e --- /dev/null +++ b/apps/backend/internal/planet/internal/allegiances/store_contract_test.go @@ -0,0 +1,19 @@ +package allegiances_test + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/suite" + + "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/allegiances" +) + +func TestTheMemoryStoreKeepsTheContract(t *testing.T) { + suite.Run(t, &allegiances.StoreContractSuite{ + NewStore: func() allegiances.Store { return allegiances.NewMemoryStore() }, + Account: func(n int) allegiances.Account { + return allegiances.Account(fmt.Sprintf("01926c6e-7a4b-7c3d-8e9f-0a1b2c3d4e%02d", n)) + }, + }) +} diff --git a/apps/backend/internal/planet/internal/allegiances/store_contract_testing.go b/apps/backend/internal/planet/internal/allegiances/store_contract_testing.go new file mode 100644 index 000000000..f27931c75 --- /dev/null +++ b/apps/backend/internal/planet/internal/allegiances/store_contract_testing.go @@ -0,0 +1,95 @@ +//go:build testing + +package allegiances + +import ( + "errors" + "sync" + "time" + + "github.com/stretchr/testify/suite" +) + +type StoreContractSuite struct { + suite.Suite + + NewStore func() Store + Account func(n int) Account + + store Store +} + +func (s *StoreContractSuite) SetupTest() { + s.store = s.NewStore() +} + +var contractAt = time.Date(2026, 10, 23, 12, 30, 0, 123456000, time.UTC) + +func (s *StoreContractSuite) keep(account Account, kept Snapshot) { + s.Require().NoError(s.store.Change(s.T().Context(), account, func(Allegiance) (Allegiance, error) { + return kept.Restore(), nil + })) +} + +func (s *StoreContractSuite) kept(account Account) Snapshot { + var seen Snapshot + s.Require().NoError(s.store.Change(s.T().Context(), account, func(allegiance Allegiance) (Allegiance, error) { + seen = allegiance.TakeSnapshot() + return allegiance, nil + })) + return seen +} + +func (s *StoreContractSuite) TestAnAccountNeverChangedStartsFromNothing() { + s.Equal(Snapshot{}, s.kept(s.Account(1))) +} + +func (s *StoreContractSuite) TestAChangeIsKeptAndSeenByTheNext() { + s.keep(s.Account(1), Snapshot{Flag: "fr"}) + s.keep(s.Account(2), Snapshot{Flag: "de", ChangedAt: contractAt}) + + s.Equal(Snapshot{Flag: "fr"}, s.kept(s.Account(1)), "a first flag keeps no time") + s.True(contractAt.Equal(s.kept(s.Account(2)).ChangedAt)) + s.Equal("de", s.kept(s.Account(2)).Flag) +} + +func (s *StoreContractSuite) TestAChangeReplacesWhatWasKept() { + s.keep(s.Account(1), Snapshot{Flag: "fr"}) + + s.keep(s.Account(1), Snapshot{Flag: "de", ChangedAt: contractAt}) + + s.Equal("de", s.kept(s.Account(1)).Flag) + s.True(contractAt.Equal(s.kept(s.Account(1)).ChangedAt)) +} + +func (s *StoreContractSuite) TestARefusedChangeKeepsNothingAndSaysWhy() { + s.keep(s.Account(1), Snapshot{Flag: "fr"}) + refused := errors.New("refused") + + err := s.store.Change(s.T().Context(), s.Account(1), func(Allegiance) (Allegiance, error) { + return Snapshot{Flag: "de"}.Restore(), refused + }) + + s.Require().ErrorIs(err, refused) + s.Equal(Snapshot{Flag: "fr"}, s.kept(s.Account(1))) +} + +func (s *StoreContractSuite) TestChangesToOneAccountNeverOverlap() { + const changes = 20 + s.keep(s.Account(1), Snapshot{Flag: "fr", ChangedAt: contractAt}) + + var wg sync.WaitGroup + for range changes { + wg.Add(1) + go func() { + defer wg.Done() + s.NoError(s.store.Change(s.T().Context(), s.Account(1), func(allegiance Allegiance) (Allegiance, error) { + kept := allegiance.TakeSnapshot() + return Snapshot{Flag: kept.Flag, ChangedAt: kept.ChangedAt.Add(time.Second)}.Restore(), nil + })) + }() + } + wg.Wait() + + s.True(contractAt.Add(changes*time.Second).Equal(s.kept(s.Account(1)).ChangedAt), "each change saw the one before it") +} diff --git a/apps/backend/internal/planet/internal/allegiances/store_testing.go b/apps/backend/internal/planet/internal/allegiances/store_testing.go new file mode 100644 index 000000000..e78209143 --- /dev/null +++ b/apps/backend/internal/planet/internal/allegiances/store_testing.go @@ -0,0 +1,52 @@ +//go:build testing + +package allegiances + +import ( + "context" + "sync" +) + +var _ Store = (*MemoryStore)(nil) + +type MemoryStore struct { + mu sync.Mutex + rows map[Account]Snapshot + failing error +} + +func NewMemoryStore() *MemoryStore { + return &MemoryStore{rows: map[Account]Snapshot{}} +} + +func (m *MemoryStore) Change(_ context.Context, account Account, change func(Allegiance) (Allegiance, error)) error { + m.mu.Lock() + defer m.mu.Unlock() + + if m.failing != nil { + return m.failing + } + before := m.rows[account] + after, err := change(before.Restore()) + if err != nil { + return err + } + if kept := after.TakeSnapshot(); kept != before { + m.rows[account] = kept + } + return nil +} + +func (m *MemoryStore) Kept(account Account) Snapshot { + m.mu.Lock() + defer m.mu.Unlock() + + return m.rows[account] +} + +func (m *MemoryStore) FailWith(err error) { + m.mu.Lock() + defer m.mu.Unlock() + + m.failing = err +} 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 3545f98b9..1983cca66 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 @@ -28,7 +28,7 @@ type CountryChecker interface { } type Allegiances interface { - Pledge(payer clicks.Payer, flag string) error + Pledge(ctx context.Context, payer clicks.Payer, flag string) error } type In struct { @@ -63,7 +63,7 @@ func (u *UseCase) Execute(ctx context.Context, in In) (bonuses.Held, error) { } payer := clicks.PayerOf(ctx) - if err := u.allegiances.Pledge(payer, in.CountryID); err != nil { + if err := u.allegiances.Pledge(ctx, payer, in.CountryID); err != nil { return bonuses.Held{}, err } 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 6aa06829a..6dabadb2c 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 @@ -10,7 +10,6 @@ import ( "github.com/stretchr/testify/require" "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/allegiances" - "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/allegiances/inmemory_allegiance_storage" "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/bonuses" "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/bonuses/inmemory_charge_storage" "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/bonuses/usecases/place_shield_usecase" @@ -45,8 +44,7 @@ func setup(t *testing.T, shields int) fixture { require.NoError(t, tiles.Load(t.Context())) clock := cptime.NewFixedClock(time.Date(2026, 10, 17, 12, 0, 0, 0, time.UTC)) - book := allegiances.NewBook(inmemory_allegiance_storage.New(inmemory_allegiance_storage.Config{}, - inmemory_allegiance_storage.NewMemoryPersistence(), slog.New(slog.DiscardHandler)), cpcountries.New(), clock) + book := allegiances.NewBook(allegiances.NewMemoryStore(), cpcountries.New(), clock) return fixture{ charges: charges, diff --git a/apps/backend/internal/planet/internal/clicks/usecases/click_usecase/allegiance_click/usecase.go b/apps/backend/internal/planet/internal/clicks/usecases/click_usecase/allegiance_click/usecase.go index 1260883d9..5b42f732f 100644 --- a/apps/backend/internal/planet/internal/clicks/usecases/click_usecase/allegiance_click/usecase.go +++ b/apps/backend/internal/planet/internal/clicks/usecases/click_usecase/allegiance_click/usecase.go @@ -8,7 +8,7 @@ import ( ) type Allegiances interface { - Pledge(payer clicks.Payer, flag string) error + Pledge(ctx context.Context, payer clicks.Payer, flag string) error } func New(implementation click_usecase.IUseCase, allegiances Allegiances) *UseCase { @@ -21,7 +21,7 @@ type UseCase struct { } func (u *UseCase) Execute(ctx context.Context, in click_usecase.In) (click_usecase.Out, error) { - if err := u.allegiances.Pledge(clicks.PayerOf(ctx), in.CountryID); err != nil { + if err := u.allegiances.Pledge(ctx, clicks.PayerOf(ctx), in.CountryID); err != nil { return click_usecase.Out{}, err } diff --git a/apps/backend/internal/planet/internal/clicks/usecases/click_usecase/allegiance_click/usecase_test.go b/apps/backend/internal/planet/internal/clicks/usecases/click_usecase/allegiance_click/usecase_test.go index 2c92128d2..84bcd809d 100644 --- a/apps/backend/internal/planet/internal/clicks/usecases/click_usecase/allegiance_click/usecase_test.go +++ b/apps/backend/internal/planet/internal/clicks/usecases/click_usecase/allegiance_click/usecase_test.go @@ -2,7 +2,6 @@ package allegiance_click_test import ( "context" - "log/slog" "testing" "time" @@ -10,7 +9,6 @@ import ( "github.com/stretchr/testify/require" "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/allegiances" - "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/allegiances/inmemory_allegiance_storage" "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/allegiance_click" "github.com/raphoester/clickplanet.lol-backend/internal/shared/cpcountries" @@ -32,12 +30,10 @@ func (c *countingClicks) Execute(_ context.Context, in click_usecase.In) (click_ func setup(t *testing.T) (*allegiance_click.UseCase, *countingClicks, *cptime.FixedClock) { t.Helper() - storage := inmemory_allegiance_storage.New(inmemory_allegiance_storage.Config{}, - inmemory_allegiance_storage.NewMemoryPersistence(), slog.New(slog.DiscardHandler)) clock := cptime.NewFixedClock(time.Date(2026, 10, 17, 12, 0, 0, 0, time.UTC)) inner := &countingClicks{} - return allegiance_click.New(inner, allegiances.NewBook(storage, cpcountries.New(), clock)), inner, clock + return allegiance_click.New(inner, allegiances.NewBook(allegiances.NewMemoryStore(), cpcountries.New(), clock)), inner, clock } func clickFor(t *testing.T, useCase *allegiance_click.UseCase, flag string) error { diff --git a/apps/backend/internal/planet/internal/planetv1controller/get_allegiance_handler/allegiance_query/query_memory.go b/apps/backend/internal/planet/internal/planetv1controller/get_allegiance_handler/allegiance_query/query_memory.go deleted file mode 100644 index 04983f6ab..000000000 --- a/apps/backend/internal/planet/internal/planetv1controller/get_allegiance_handler/allegiance_query/query_memory.go +++ /dev/null @@ -1,34 +0,0 @@ -package allegiance_query - -import ( - planetv1 "github.com/raphoester/clickplanet.lol-backend/generated/proto/planet/v1" - "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/allegiances" - "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/planetv1controller/allegiancemessage" -) - -type Allegiances interface { - Snapshot(account allegiances.Account) allegiances.Snapshot -} - -func NewMemoryQuery(allegiances Allegiances) *MemoryQuery { - return &MemoryQuery{allegiances: allegiances} -} - -type MemoryQuery struct { - allegiances Allegiances -} - -func (q *MemoryQuery) Allegiance(account allegiances.Account) *planetv1.GetAllegianceResponse { - if account == allegiances.NoAccount { - return &planetv1.GetAllegianceResponse{} - } - - kept := q.allegiances.Snapshot(account) - if kept.Flag == "" { - return &planetv1.GetAllegianceResponse{} - } - - return &planetv1.GetAllegianceResponse{ - Allegiance: allegiancemessage.Encode(kept.Flag, allegiances.ChangedAt(kept.ChangedAt).ChangeableAt()), - } -} diff --git a/apps/backend/internal/planet/internal/planetv1controller/get_allegiance_handler/allegiance_query/query_memory_test.go b/apps/backend/internal/planet/internal/planetv1controller/get_allegiance_handler/allegiance_query/query_memory_test.go deleted file mode 100644 index 7ab1a5fb5..000000000 --- a/apps/backend/internal/planet/internal/planetv1controller/get_allegiance_handler/allegiance_query/query_memory_test.go +++ /dev/null @@ -1,44 +0,0 @@ -package allegiance_query_test - -import ( - "testing" - "time" - - "github.com/stretchr/testify/assert" - "google.golang.org/protobuf/proto" - - planetv1 "github.com/raphoester/clickplanet.lol-backend/generated/proto/planet/v1" - "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/allegiances" - "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/planetv1controller/get_allegiance_handler/allegiance_query" -) - -const alice allegiances.Account = "01926c6e-7a4b-7c3d-8e9f-0a1b2c3d4e5f" - -type stubAllegiances map[allegiances.Account]allegiances.Snapshot - -func (s stubAllegiances) Snapshot(account allegiances.Account) allegiances.Snapshot { - return s[account] -} - -func TestAnAllegianceSaysWhenItMayChange(t *testing.T) { - changed := time.Date(2026, 10, 17, 12, 0, 0, 0, time.UTC) - query := allegiance_query.NewMemoryQuery(stubAllegiances{alice: {Flag: "de", ChangedAt: changed}}) - - assert.True(t, proto.Equal(&planetv1.GetAllegianceResponse{Allegiance: &planetv1.Allegiance{ - CountryId: "de", ChangeableAtUnixMs: changed.Add(time.Hour).UnixMilli(), - }}, query.Allegiance(alice))) -} - -func TestAFirstFlagMayChangeNow(t *testing.T) { - query := allegiance_query.NewMemoryQuery(stubAllegiances{alice: {Flag: "fr"}}) - - assert.True(t, proto.Equal(&planetv1.GetAllegianceResponse{Allegiance: &planetv1.Allegiance{CountryId: "fr"}}, - query.Allegiance(alice))) -} - -func TestNoFlagYetAndNoAccountAnswerNoAllegiance(t *testing.T) { - query := allegiance_query.NewMemoryQuery(stubAllegiances{alice: {Flag: "fr"}}) - - assert.Nil(t, query.Allegiance("01926c6e-7a4b-7c3d-8e9f-0a1b2c3d4e60").GetAllegiance()) - assert.Nil(t, query.Allegiance(allegiances.NoAccount).GetAllegiance()) -} diff --git a/apps/backend/internal/planet/internal/planetv1controller/get_allegiance_handler/allegiance_query/query_postgres.go b/apps/backend/internal/planet/internal/planetv1controller/get_allegiance_handler/allegiance_query/query_postgres.go new file mode 100644 index 000000000..a60ba86cf --- /dev/null +++ b/apps/backend/internal/planet/internal/planetv1controller/get_allegiance_handler/allegiance_query/query_postgres.go @@ -0,0 +1,44 @@ +package allegiance_query + +import ( + "context" + "database/sql" + "errors" + "fmt" + + planetv1 "github.com/raphoester/clickplanet.lol-backend/generated/proto/planet/v1" + "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/allegiances" + "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/planetv1controller/allegiancemessage" + "github.com/raphoester/clickplanet.lol-backend/internal/shared/cppg" +) + +func NewPostgresQuery(db cppg.Querier) *PostgresQuery { + return &PostgresQuery{db: db} +} + +type PostgresQuery struct { + db cppg.Querier +} + +func (q *PostgresQuery) Allegiance(ctx context.Context, account allegiances.Account) (*planetv1.GetAllegianceResponse, error) { + if account == allegiances.NoAccount { + return &planetv1.GetAllegianceResponse{}, nil + } + + var ( + flag string + changedAt sql.NullTime + ) + err := q.db.QueryRowContext(ctx, `SELECT flag, changed_at FROM allegiances WHERE account = $1`, string(account)). + Scan(&flag, &changedAt) + switch { + case errors.Is(err, sql.ErrNoRows): + return &planetv1.GetAllegianceResponse{}, nil + case err != nil: + return nil, fmt.Errorf("failed to read the allegiance of %s: %w", account, err) + } + + return &planetv1.GetAllegianceResponse{ + Allegiance: allegiancemessage.Encode(flag, allegiances.ChangedAt(changedAt.Time).ChangeableAt()), + }, nil +} diff --git a/apps/backend/internal/planet/internal/planetv1controller/get_allegiance_handler/allegiance_query/query_postgres_test.go b/apps/backend/internal/planet/internal/planetv1controller/get_allegiance_handler/allegiance_query/query_postgres_test.go new file mode 100644 index 000000000..cbc70a764 --- /dev/null +++ b/apps/backend/internal/planet/internal/planetv1controller/get_allegiance_handler/allegiance_query/query_postgres_test.go @@ -0,0 +1,78 @@ +package allegiance_query_test + +import ( + "testing" + "time" + + "github.com/stretchr/testify/suite" + "google.golang.org/protobuf/proto" + + planetv1 "github.com/raphoester/clickplanet.lol-backend/generated/proto/planet/v1" + "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/allegiances" + "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/allegiances/postgres_allegiance_store" + "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/migrations" + "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/planetv1controller/get_allegiance_handler/allegiance_query" + "github.com/raphoester/clickplanet.lol-backend/internal/shared/cppg" +) + +func TestRunSuite(t *testing.T) { + suite.Run(t, new(testSuite)) +} + +type testSuite struct { + suite.Suite + db *cppg.Postgres + store *postgres_allegiance_store.Store + query *allegiance_query.PostgresQuery +} + +func (s *testSuite) SetupSuite() { + s.db = cppg.StartTestServer(s.T()).OpenSchema(s.T(), "planet", migrations.FS) + s.store = postgres_allegiance_store.New(s.db) + s.query = allegiance_query.NewPostgresQuery(s.db) +} + +func (s *testSuite) SetupTest() { + s.Require().NoError(s.db.Purge(s.T().Context())) +} + +const ( + alice allegiances.Account = "01926c6e-7a4b-7c3d-8e9f-0a1b2c3d4e5f" + bob allegiances.Account = "01926c6e-7a4b-7c3d-8e9f-0a1b2c3d4e60" +) + +var changed = time.Date(2026, 10, 23, 12, 0, 0, 0, time.UTC) + +func (s *testSuite) keep(account allegiances.Account, kept allegiances.Snapshot) { + s.Require().NoError(s.store.Change(s.T().Context(), account, func(allegiances.Allegiance) (allegiances.Allegiance, error) { + return kept.Restore(), nil + })) +} + +func (s *testSuite) allegiance(account allegiances.Account) *planetv1.GetAllegianceResponse { + answer, err := s.query.Allegiance(s.T().Context(), account) + s.Require().NoError(err) + return answer +} + +func (s *testSuite) TestAnAllegianceSaysWhenItMayChange() { + s.keep(alice, allegiances.Snapshot{Flag: "de", ChangedAt: changed}) + + s.True(proto.Equal(&planetv1.GetAllegianceResponse{Allegiance: &planetv1.Allegiance{ + CountryId: "de", ChangeableAtUnixMs: changed.Add(time.Hour).UnixMilli(), + }}, s.allegiance(alice))) +} + +func (s *testSuite) TestAFirstFlagMayChangeNow() { + s.keep(alice, allegiances.Snapshot{Flag: "fr"}) + + s.True(proto.Equal(&planetv1.GetAllegianceResponse{Allegiance: &planetv1.Allegiance{CountryId: "fr"}}, + s.allegiance(alice))) +} + +func (s *testSuite) TestNoFlagYetAndNoAccountAnswerNoAllegiance() { + s.keep(alice, allegiances.Snapshot{Flag: "fr"}) + + s.Nil(s.allegiance(bob).GetAllegiance()) + s.Nil(s.allegiance(allegiances.NoAccount).GetAllegiance()) +} diff --git a/apps/backend/internal/planet/internal/planetv1controller/get_allegiance_handler/handler.go b/apps/backend/internal/planet/internal/planetv1controller/get_allegiance_handler/handler.go index ee8e7340d..693ef3330 100644 --- a/apps/backend/internal/planet/internal/planetv1controller/get_allegiance_handler/handler.go +++ b/apps/backend/internal/planet/internal/planetv1controller/get_allegiance_handler/handler.go @@ -11,7 +11,7 @@ import ( ) type Query interface { - Allegiance(account allegiances.Account) *planetv1.GetAllegianceResponse + Allegiance(ctx context.Context, account allegiances.Account) (*planetv1.GetAllegianceResponse, error) } func New(query Query) GetAllegianceHandler { @@ -26,5 +26,9 @@ func (h GetAllegianceHandler) GetAllegiance( ctx context.Context, _ *connect.Request[planetv1.GetAllegianceRequest], ) (*connect.Response[planetv1.GetAllegianceResponse], error) { - return connect.NewResponse(h.query.Allegiance(allegiances.AccountOf(clicks.PayerOf(ctx)))), nil + allegiance, err := h.query.Allegiance(ctx, allegiances.AccountOf(clicks.PayerOf(ctx))) + if err != nil { + return nil, err //nolint:wrapcheck // the error net answers what is not the caller's fault. + } + return connect.NewResponse(allegiance), nil } diff --git a/apps/backend/internal/planet/module.go b/apps/backend/internal/planet/module.go index 6c22ad13c..2d8441a66 100644 --- a/apps/backend/internal/planet/module.go +++ b/apps/backend/internal/planet/module.go @@ -14,7 +14,6 @@ import ( "github.com/raphoester/clickplanet.lol-backend/internal/antibot" "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/allegiances" - "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/allegiances/inmemory_allegiance_storage" "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/allegiances/postgres_allegiance_store" "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/bonuses" "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/bonuses/inmemory_charge_storage" @@ -185,15 +184,8 @@ func NewModule(config Config) cpbootstrap.Module { return fmt.Errorf("failed to load the charges: %w", err) } - allegianceStorage := inmemory_allegiance_storage.New(config.AllegianceStorage, - postgres_allegiance_store.New(db), props.Logger) - if err := allegianceStorage.Load(ctx); err != nil { - _ = db.Close() - return fmt.Errorf("failed to load the allegiances: %w", err) - } - // Not a closer: closers run before the runners' last flush. - props.Runners.Add(cppg.CloseAfter(db, props.Logger, tilesStorage, takings, charges, allegianceStorage, deletions)) + props.Runners.Add(cppg.CloseAfter(db, props.Logger, tilesStorage, takings, charges, deletions)) props.Runners.Add(ledger.NewRetention(config.Ledger, takings, clock)) props.Runners.Add(relay_fortifications_usecase.New(tilesStorage, props.Events, clock, props.Logger)) @@ -203,7 +195,7 @@ func NewModule(config Config) cpbootstrap.Module { pricer := clicks.NewToll(config.Toll, tilesStorage) - book := allegiances.NewBook(allegianceStorage, countries, clock) + book := allegiances.NewBook(postgres_allegiance_store.New(db), countries, clock) shielding := clicks.NewShielding(tilesStorage) @@ -391,7 +383,7 @@ func NewModule(config Config) cpbootstrap.Module { PlaceShieldHandler: place_shield_handler.New(antibot_place_shield.New( place_shield_usecase.New(charges, writer, countries, book, config.Bonus.ShieldsPerTile()), guard)), GetFortressesHandler: get_fortresses_handler.New(fortresses_query.NewMemoryQuery(tilesStorage)), - GetAllegianceHandler: get_allegiance_handler.New(allegiance_query.NewMemoryQuery(allegianceStorage)), + GetAllegianceHandler: get_allegiance_handler.New(allegiance_query.NewPostgresQuery(db)), } return props.RPC.Mount(func(options ...connect.HandlerOption) (string, http.Handler) {