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 cfc67da00..3b332ef4e 100644 --- a/apps/backend/CLAUDE.md +++ b/apps/backend/CLAUDE.md @@ -353,6 +353,8 @@ internal/planet/internal/ inmemory_charge_storage/ postgres_charge_store/ usecases/ + allegiances/ the flag each account plays for, and when it last changed it + 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 @@ -423,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 @@ -435,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. @@ -726,6 +728,7 @@ POST /planet.v1.ClickService/Click [X-Session-Token: ] → ClickService → click_handler → ban_click (a banned caller is PermissionDenied, with a BanRefusal: when the ban ends) → antibot_attempt_click (times every try for the metronome; refuses nothing) + → 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, and flags a caller it finds guilty; refuses nothing) → prom_click (counts) @@ -866,6 +869,45 @@ 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(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`, + between `antibot_attempt_click` and `throttle_click`): a banned caller is told its + ban first and changes no flag, and a click refused for its flag spends no token. + `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`, 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. + ### 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. 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 d71710c37..54367cdf4 100644 --- a/apps/backend/e2e/player_test.go +++ b/apps/backend/e2e/player_test.go @@ -407,11 +407,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") @@ -516,8 +514,9 @@ func TestAnOperatorReadsWhoPlayersAreAndAGuestsProfileOnTheAdminListenerOnly(t * require.NoError(t, err) guest := game.newPlayer(t) require.NoError(t, guest.announce("de")) - for tile, country := range []string{"de", "fr", "it", "es"} { - guest.click(uint32(tile)+1, country) //nolint:gosec // four tiles. + for tile, country := range []string{"fr", "it", "es", "pt"} { + game.newPlayer(t).click(uint32(tile)+1, country) //nolint:gosec // four tiles. + guest.click(uint32(tile)+1, "de") //nolint:gosec // four tiles. } names := connect.NewRequest(&playerv1.GetAuthorsRequest{AccountIds: []string{ada.account(), guest.account()}}) @@ -534,13 +533,13 @@ func TestAnOperatorReadsWhoPlayersAreAndAGuestsProfileOnTheAdminListenerOnly(t * asked := connect.NewRequest(&playerv1.AdminGetPlayerRequest{AccountId: guest.account()}) everyCountry := []*playerv1.CountryTiles{ - {CountryId: "de", Tiles: 1}, {CountryId: "es", Tiles: 1}, {CountryId: "fr", Tiles: 1}, {CountryId: "it", Tiles: 1}, + {CountryId: "es", Tiles: 1}, {CountryId: "fr", Tiles: 1}, {CountryId: "it", Tiles: 1}, {CountryId: "pt", Tiles: 1}, } require.Eventually(t, func() bool { res, err := admin.GetPlayer(t.Context(), asked) return err == nil && res.Msg.GetStats().GetTilesTaken() == 4 && proto.Equal( - &playerv1.AdminGetPlayerResponse{PlaysFor: everyCountry}, - &playerv1.AdminGetPlayerResponse{PlaysFor: res.Msg.GetPlaysFor()}, + &playerv1.AdminGetPlayerResponse{PlaysFor: []*playerv1.CountryTiles{{CountryId: "de", Tiles: 4}}, PlaysAgainst: everyCountry}, + &playerv1.AdminGetPlayerResponse{PlaysFor: res.Msg.GetPlaysFor(), PlaysAgainst: res.Msg.GetPlaysAgainst()}, ) }, 5*time.Second, 20*time.Millisecond, "every country, where the public card has three and a guest no card at all") profile, err := admin.GetPlayer(t.Context(), asked) diff --git a/apps/backend/generated/proto/planet/v1/planet.pb.go b/apps/backend/generated/proto/planet/v1/planet.pb.go index 1fe050735..60bddc4ae 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 { @@ -361,7 +496,7 @@ type BanRefusal struct { func (x *BanRefusal) Reset() { *x = BanRefusal{} - 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) } @@ -373,7 +508,7 @@ func (x *BanRefusal) String() string { func (*BanRefusal) ProtoMessage() {} func (x *BanRefusal) 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 { @@ -386,7 +521,7 @@ func (x *BanRefusal) ProtoReflect() protoreflect.Message { // Deprecated: Use BanRefusal.ProtoReflect.Descriptor instead. func (*BanRefusal) Descriptor() ([]byte, []int) { - return file_planet_v1_planet_proto_rawDescGZIP(), []int{3} + return file_planet_v1_planet_proto_rawDescGZIP(), []int{6} } func (x *BanRefusal) GetBannedUntilUnixMs() int64 { @@ -405,7 +540,7 @@ type GetBudgetRequest struct { func (x *GetBudgetRequest) Reset() { *x = GetBudgetRequest{} - 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) } @@ -417,7 +552,7 @@ func (x *GetBudgetRequest) String() string { func (*GetBudgetRequest) ProtoMessage() {} func (x *GetBudgetRequest) 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 { @@ -430,7 +565,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{4} + return file_planet_v1_planet_proto_rawDescGZIP(), []int{7} } func (x *GetBudgetRequest) GetCountryId() string { @@ -449,7 +584,7 @@ type GetBudgetResponse struct { func (x *GetBudgetResponse) Reset() { *x = GetBudgetResponse{} - 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) } @@ -461,7 +596,7 @@ func (x *GetBudgetResponse) String() string { func (*GetBudgetResponse) ProtoMessage() {} func (x *GetBudgetResponse) 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 { @@ -474,7 +609,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{5} + return file_planet_v1_planet_proto_rawDescGZIP(), []int{8} } func (x *GetBudgetResponse) GetBudget() *ClickBudget { @@ -492,7 +627,7 @@ type MapDensityRequest struct { func (x *MapDensityRequest) Reset() { *x = MapDensityRequest{} - 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) } @@ -504,7 +639,7 @@ func (x *MapDensityRequest) String() string { func (*MapDensityRequest) ProtoMessage() {} func (x *MapDensityRequest) 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 { @@ -517,7 +652,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{6} + return file_planet_v1_planet_proto_rawDescGZIP(), []int{9} } type MapDensityResponse struct { @@ -529,7 +664,7 @@ type MapDensityResponse struct { func (x *MapDensityResponse) Reset() { *x = MapDensityResponse{} - 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 *MapDensityResponse) String() string { func (*MapDensityResponse) ProtoMessage() {} func (x *MapDensityResponse) 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 *MapDensityResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use MapDensityResponse.ProtoReflect.Descriptor instead. func (*MapDensityResponse) Descriptor() ([]byte, []int) { - return file_planet_v1_planet_proto_rawDescGZIP(), []int{7} + return file_planet_v1_planet_proto_rawDescGZIP(), []int{10} } func (x *MapDensityResponse) GetDensity() uint32 { @@ -574,7 +709,7 @@ type GetMapRequest struct { func (x *GetMapRequest) Reset() { *x = GetMapRequest{} - 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) } @@ -586,7 +721,7 @@ func (x *GetMapRequest) String() string { func (*GetMapRequest) ProtoMessage() {} func (x *GetMapRequest) 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 { @@ -599,7 +734,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{8} + return file_planet_v1_planet_proto_rawDescGZIP(), []int{11} } func (x *GetMapRequest) GetStartTileId() uint32 { @@ -628,7 +763,7 @@ type GetMapResponse struct { func (x *GetMapResponse) Reset() { *x = GetMapResponse{} - 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) } @@ -640,7 +775,7 @@ func (x *GetMapResponse) String() string { func (*GetMapResponse) ProtoMessage() {} func (x *GetMapResponse) 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 { @@ -653,7 +788,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{9} + return file_planet_v1_planet_proto_rawDescGZIP(), []int{12} } func (x *GetMapResponse) GetStartTileId() uint32 { @@ -694,7 +829,7 @@ type TileShields struct { func (x *TileShields) Reset() { *x = TileShields{} - 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) } @@ -706,7 +841,7 @@ func (x *TileShields) String() string { func (*TileShields) ProtoMessage() {} func (x *TileShields) 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 { @@ -719,7 +854,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{10} + return file_planet_v1_planet_proto_rawDescGZIP(), []int{13} } func (x *TileShields) GetTileId() uint32 { @@ -744,7 +879,7 @@ type ListenForEventsRequest struct { func (x *ListenForEventsRequest) Reset() { *x = ListenForEventsRequest{} - 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) } @@ -756,7 +891,7 @@ func (x *ListenForEventsRequest) String() string { func (*ListenForEventsRequest) ProtoMessage() {} func (x *ListenForEventsRequest) 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 { @@ -769,7 +904,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{11} + return file_planet_v1_planet_proto_rawDescGZIP(), []int{14} } type PlanetEvent struct { @@ -792,7 +927,7 @@ type PlanetEvent struct { func (x *PlanetEvent) Reset() { *x = PlanetEvent{} - 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) } @@ -804,7 +939,7 @@ func (x *PlanetEvent) String() string { func (*PlanetEvent) ProtoMessage() {} func (x *PlanetEvent) 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 { @@ -817,7 +952,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{12} + return file_planet_v1_planet_proto_rawDescGZIP(), []int{15} } func (x *PlanetEvent) GetEvent() isPlanetEvent_Event { @@ -974,7 +1109,7 @@ type GetFortressesRequest struct { func (x *GetFortressesRequest) Reset() { *x = GetFortressesRequest{} - 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) } @@ -986,7 +1121,7 @@ func (x *GetFortressesRequest) String() string { func (*GetFortressesRequest) ProtoMessage() {} func (x *GetFortressesRequest) 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 { @@ -999,7 +1134,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{13} + return file_planet_v1_planet_proto_rawDescGZIP(), []int{16} } // Every landmass a flag fortified last, or holds whole with nobody having fortified it. @@ -1012,7 +1147,7 @@ type GetFortressesResponse struct { func (x *GetFortressesResponse) Reset() { *x = GetFortressesResponse{} - 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) } @@ -1024,7 +1159,7 @@ func (x *GetFortressesResponse) String() string { func (*GetFortressesResponse) ProtoMessage() {} func (x *GetFortressesResponse) 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 { @@ -1037,7 +1172,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{14} + return file_planet_v1_planet_proto_rawDescGZIP(), []int{17} } func (x *GetFortressesResponse) GetFortresses() []*Fortress { @@ -1058,7 +1193,7 @@ type Fortress struct { func (x *Fortress) Reset() { *x = Fortress{} - 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) } @@ -1070,7 +1205,7 @@ func (x *Fortress) String() string { func (*Fortress) ProtoMessage() {} func (x *Fortress) 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 { @@ -1083,7 +1218,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{15} + return file_planet_v1_planet_proto_rawDescGZIP(), []int{18} } func (x *Fortress) GetLandmassId() uint32 { @@ -1114,7 +1249,7 @@ type LandmassFortified struct { func (x *LandmassFortified) Reset() { *x = LandmassFortified{} - 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) } @@ -1126,7 +1261,7 @@ func (x *LandmassFortified) String() string { func (*LandmassFortified) ProtoMessage() {} func (x *LandmassFortified) 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 { @@ -1139,7 +1274,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{16} + return file_planet_v1_planet_proto_rawDescGZIP(), []int{19} } func (x *LandmassFortified) GetLandmassId() uint32 { @@ -1176,7 +1311,7 @@ type ChargesHeld struct { func (x *ChargesHeld) Reset() { *x = ChargesHeld{} - 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) } @@ -1188,7 +1323,7 @@ func (x *ChargesHeld) String() string { func (*ChargesHeld) ProtoMessage() {} func (x *ChargesHeld) 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 { @@ -1201,7 +1336,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{17} + return file_planet_v1_planet_proto_rawDescGZIP(), []int{20} } func (x *ChargesHeld) GetRefill() bool { @@ -1247,7 +1382,7 @@ type GetChargesRequest struct { func (x *GetChargesRequest) Reset() { *x = GetChargesRequest{} - 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) } @@ -1259,7 +1394,7 @@ func (x *GetChargesRequest) String() string { func (*GetChargesRequest) ProtoMessage() {} func (x *GetChargesRequest) 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 { @@ -1272,7 +1407,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{18} + return file_planet_v1_planet_proto_rawDescGZIP(), []int{21} } type GetChargesResponse struct { @@ -1284,7 +1419,7 @@ type GetChargesResponse struct { func (x *GetChargesResponse) Reset() { *x = GetChargesResponse{} - 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) } @@ -1296,7 +1431,7 @@ func (x *GetChargesResponse) String() string { func (*GetChargesResponse) ProtoMessage() {} func (x *GetChargesResponse) 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 { @@ -1309,7 +1444,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{19} + return file_planet_v1_planet_proto_rawDescGZIP(), []int{22} } func (x *GetChargesResponse) GetCharges() *ChargesHeld { @@ -1327,7 +1462,7 @@ type GetBonusRulesRequest struct { func (x *GetBonusRulesRequest) Reset() { *x = GetBonusRulesRequest{} - 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) } @@ -1339,7 +1474,7 @@ func (x *GetBonusRulesRequest) String() string { func (*GetBonusRulesRequest) ProtoMessage() {} func (x *GetBonusRulesRequest) 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 { @@ -1352,7 +1487,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{20} + return file_planet_v1_planet_proto_rawDescGZIP(), []int{23} } type GetBonusRulesResponse struct { @@ -1371,7 +1506,7 @@ type GetBonusRulesResponse struct { func (x *GetBonusRulesResponse) Reset() { *x = GetBonusRulesResponse{} - 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) } @@ -1383,7 +1518,7 @@ func (x *GetBonusRulesResponse) String() string { func (*GetBonusRulesResponse) ProtoMessage() {} func (x *GetBonusRulesResponse) 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 { @@ -1396,7 +1531,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{21} + return file_planet_v1_planet_proto_rawDescGZIP(), []int{24} } func (x *GetBonusRulesResponse) GetBlastRadius() float64 { @@ -1458,7 +1593,7 @@ type TollStep struct { func (x *TollStep) Reset() { *x = TollStep{} - 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) } @@ -1470,7 +1605,7 @@ func (x *TollStep) String() string { func (*TollStep) ProtoMessage() {} func (x *TollStep) 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 { @@ -1483,7 +1618,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{22} + return file_planet_v1_planet_proto_rawDescGZIP(), []int{25} } func (x *TollStep) GetShare() float64 { @@ -1512,7 +1647,7 @@ type BonusOffered struct { func (x *BonusOffered) Reset() { *x = BonusOffered{} - 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) } @@ -1524,7 +1659,7 @@ func (x *BonusOffered) String() string { func (*BonusOffered) ProtoMessage() {} func (x *BonusOffered) 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 { @@ -1537,7 +1672,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{23} + return file_planet_v1_planet_proto_rawDescGZIP(), []int{26} } func (x *BonusOffered) GetToken() string { @@ -1579,7 +1714,7 @@ type BonusTaken struct { func (x *BonusTaken) Reset() { *x = BonusTaken{} - 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) } @@ -1591,7 +1726,7 @@ func (x *BonusTaken) String() string { func (*BonusTaken) ProtoMessage() {} func (x *BonusTaken) 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 { @@ -1604,7 +1739,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{24} + return file_planet_v1_planet_proto_rawDescGZIP(), []int{27} } func (x *BonusTaken) GetCountryId() string { @@ -1638,7 +1773,7 @@ type ClaimBonusRequest struct { func (x *ClaimBonusRequest) Reset() { *x = ClaimBonusRequest{} - 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) } @@ -1650,7 +1785,7 @@ func (x *ClaimBonusRequest) String() string { func (*ClaimBonusRequest) ProtoMessage() {} func (x *ClaimBonusRequest) 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 { @@ -1663,7 +1798,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{25} + return file_planet_v1_planet_proto_rawDescGZIP(), []int{28} } func (x *ClaimBonusRequest) GetToken() string { @@ -1691,7 +1826,7 @@ type ClaimBonusResponse struct { func (x *ClaimBonusResponse) Reset() { *x = ClaimBonusResponse{} - 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) } @@ -1703,7 +1838,7 @@ func (x *ClaimBonusResponse) String() string { func (*ClaimBonusResponse) ProtoMessage() {} func (x *ClaimBonusResponse) 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 { @@ -1716,7 +1851,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{26} + return file_planet_v1_planet_proto_rawDescGZIP(), []int{29} } func (x *ClaimBonusResponse) GetKind() BonusKind { @@ -1750,7 +1885,7 @@ type QuizOffered struct { func (x *QuizOffered) Reset() { *x = QuizOffered{} - 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) } @@ -1762,7 +1897,7 @@ func (x *QuizOffered) String() string { func (*QuizOffered) ProtoMessage() {} func (x *QuizOffered) 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 { @@ -1775,7 +1910,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{27} + return file_planet_v1_planet_proto_rawDescGZIP(), []int{30} } func (x *QuizOffered) GetToken() string { @@ -1801,7 +1936,7 @@ type OpenQuizRequest struct { func (x *OpenQuizRequest) Reset() { *x = OpenQuizRequest{} - 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) } @@ -1813,7 +1948,7 @@ func (x *OpenQuizRequest) String() string { func (*OpenQuizRequest) ProtoMessage() {} func (x *OpenQuizRequest) 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 { @@ -1826,7 +1961,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{28} + return file_planet_v1_planet_proto_rawDescGZIP(), []int{31} } func (x *OpenQuizRequest) GetToken() string { @@ -1848,7 +1983,7 @@ type OpenQuizResponse struct { func (x *OpenQuizResponse) Reset() { *x = OpenQuizResponse{} - 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) } @@ -1860,7 +1995,7 @@ func (x *OpenQuizResponse) String() string { func (*OpenQuizResponse) ProtoMessage() {} func (x *OpenQuizResponse) 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 { @@ -1873,7 +2008,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{29} + return file_planet_v1_planet_proto_rawDescGZIP(), []int{32} } func (x *OpenQuizResponse) GetQuestion() string { @@ -1915,7 +2050,7 @@ type AnswerQuizRequest struct { func (x *AnswerQuizRequest) Reset() { *x = AnswerQuizRequest{} - 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) } @@ -1927,7 +2062,7 @@ func (x *AnswerQuizRequest) String() string { func (*AnswerQuizRequest) ProtoMessage() {} func (x *AnswerQuizRequest) 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 { @@ -1940,7 +2075,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{30} + return file_planet_v1_planet_proto_rawDescGZIP(), []int{33} } func (x *AnswerQuizRequest) GetToken() string { @@ -1977,7 +2112,7 @@ type AnswerQuizResponse struct { func (x *AnswerQuizResponse) Reset() { *x = AnswerQuizResponse{} - 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) } @@ -1989,7 +2124,7 @@ func (x *AnswerQuizResponse) String() string { func (*AnswerQuizResponse) ProtoMessage() {} func (x *AnswerQuizResponse) 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 { @@ -2002,7 +2137,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{31} + return file_planet_v1_planet_proto_rawDescGZIP(), []int{34} } func (x *AnswerQuizResponse) GetCorrect() bool { @@ -2049,7 +2184,7 @@ type UseRefillRequest struct { func (x *UseRefillRequest) Reset() { *x = UseRefillRequest{} - 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 *UseRefillRequest) String() string { func (*UseRefillRequest) ProtoMessage() {} func (x *UseRefillRequest) 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 *UseRefillRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use UseRefillRequest.ProtoReflect.Descriptor instead. func (*UseRefillRequest) Descriptor() ([]byte, []int) { - return file_planet_v1_planet_proto_rawDescGZIP(), []int{32} + return file_planet_v1_planet_proto_rawDescGZIP(), []int{35} } func (x *UseRefillRequest) GetCountryId() string { @@ -2094,7 +2229,7 @@ type UseRefillResponse struct { func (x *UseRefillResponse) Reset() { *x = UseRefillResponse{} - 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) } @@ -2106,7 +2241,7 @@ func (x *UseRefillResponse) String() string { func (*UseRefillResponse) ProtoMessage() {} func (x *UseRefillResponse) 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 { @@ -2119,7 +2254,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{33} + return file_planet_v1_planet_proto_rawDescGZIP(), []int{36} } func (x *UseRefillResponse) GetBudget() *ClickBudget { @@ -2147,7 +2282,7 @@ type GlobePoint struct { func (x *GlobePoint) Reset() { *x = GlobePoint{} - 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) } @@ -2159,7 +2294,7 @@ func (x *GlobePoint) String() string { func (*GlobePoint) ProtoMessage() {} func (x *GlobePoint) 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 { @@ -2172,7 +2307,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{34} + return file_planet_v1_planet_proto_rawDescGZIP(), []int{37} } func (x *GlobePoint) GetX() float64 { @@ -2206,7 +2341,7 @@ type DropBombRequest struct { func (x *DropBombRequest) Reset() { *x = DropBombRequest{} - 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) } @@ -2218,7 +2353,7 @@ func (x *DropBombRequest) String() string { func (*DropBombRequest) ProtoMessage() {} func (x *DropBombRequest) 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 { @@ -2231,7 +2366,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{35} + return file_planet_v1_planet_proto_rawDescGZIP(), []int{38} } func (x *DropBombRequest) GetTarget() *GlobePoint { @@ -2256,7 +2391,7 @@ type DropBombResponse struct { func (x *DropBombResponse) Reset() { *x = DropBombResponse{} - 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) } @@ -2268,7 +2403,7 @@ func (x *DropBombResponse) String() string { func (*DropBombResponse) ProtoMessage() {} func (x *DropBombResponse) 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 { @@ -2281,7 +2416,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{36} + return file_planet_v1_planet_proto_rawDescGZIP(), []int{39} } type BombDropped struct { @@ -2299,7 +2434,7 @@ type BombDropped struct { func (x *BombDropped) Reset() { *x = BombDropped{} - 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) } @@ -2311,7 +2446,7 @@ func (x *BombDropped) String() string { func (*BombDropped) ProtoMessage() {} func (x *BombDropped) 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 { @@ -2324,7 +2459,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{37} + return file_planet_v1_planet_proto_rawDescGZIP(), []int{40} } func (x *BombDropped) GetTileId() uint32 { @@ -2382,7 +2517,7 @@ type TilesEnclosed struct { func (x *TilesEnclosed) Reset() { *x = TilesEnclosed{} - 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) } @@ -2394,7 +2529,7 @@ func (x *TilesEnclosed) String() string { func (*TilesEnclosed) ProtoMessage() {} func (x *TilesEnclosed) 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 { @@ -2407,7 +2542,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{38} + return file_planet_v1_planet_proto_rawDescGZIP(), []int{41} } func (x *TilesEnclosed) GetCountryId() string { @@ -2456,7 +2591,7 @@ type TilesSpread struct { func (x *TilesSpread) Reset() { *x = TilesSpread{} - 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) } @@ -2468,7 +2603,7 @@ func (x *TilesSpread) String() string { func (*TilesSpread) ProtoMessage() {} func (x *TilesSpread) 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 { @@ -2481,7 +2616,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{39} + return file_planet_v1_planet_proto_rawDescGZIP(), []int{42} } func (x *TilesSpread) GetCountryId() string { @@ -2515,7 +2650,7 @@ type PlaceShieldRequest struct { func (x *PlaceShieldRequest) Reset() { *x = PlaceShieldRequest{} - 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) } @@ -2527,7 +2662,7 @@ func (x *PlaceShieldRequest) String() string { func (*PlaceShieldRequest) ProtoMessage() {} func (x *PlaceShieldRequest) 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 { @@ -2540,7 +2675,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{40} + return file_planet_v1_planet_proto_rawDescGZIP(), []int{43} } func (x *PlaceShieldRequest) GetTileId() uint32 { @@ -2566,7 +2701,7 @@ type PlaceShieldResponse struct { func (x *PlaceShieldResponse) Reset() { *x = PlaceShieldResponse{} - 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) } @@ -2578,7 +2713,7 @@ func (x *PlaceShieldResponse) String() string { func (*PlaceShieldResponse) ProtoMessage() {} func (x *PlaceShieldResponse) 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 { @@ -2591,7 +2726,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{41} + return file_planet_v1_planet_proto_rawDescGZIP(), []int{44} } func (x *PlaceShieldResponse) GetCharges() *ChargesHeld { @@ -2609,7 +2744,7 @@ type Heartbeat struct { func (x *Heartbeat) Reset() { *x = Heartbeat{} - 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) } @@ -2621,7 +2756,7 @@ func (x *Heartbeat) String() string { func (*Heartbeat) ProtoMessage() {} func (x *Heartbeat) 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 { @@ -2634,7 +2769,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{42} + return file_planet_v1_planet_proto_rawDescGZIP(), []int{45} } type TileUpdate struct { @@ -2652,7 +2787,7 @@ type TileUpdate struct { func (x *TileUpdate) Reset() { *x = TileUpdate{} - mi := &file_planet_v1_planet_proto_msgTypes[43] + mi := &file_planet_v1_planet_proto_msgTypes[46] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2664,7 +2799,7 @@ func (x *TileUpdate) String() string { func (*TileUpdate) ProtoMessage() {} func (x *TileUpdate) ProtoReflect() protoreflect.Message { - mi := &file_planet_v1_planet_proto_msgTypes[43] + mi := &file_planet_v1_planet_proto_msgTypes[46] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2677,7 +2812,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{43} + return file_planet_v1_planet_proto_rawDescGZIP(), []int{46} } func (x *TileUpdate) GetTileId() uint32 { @@ -2719,7 +2854,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" + @@ -2922,7 +3067,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" + @@ -2941,7 +3086,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" @@ -2958,116 +3104,122 @@ 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, 44) +var file_planet_v1_planet_proto_msgTypes = make([]protoimpl.MessageInfo, 47) 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 - (*BanRefusal)(nil), // 5: planet.v1.BanRefusal - (*GetBudgetRequest)(nil), // 6: planet.v1.GetBudgetRequest - (*GetBudgetResponse)(nil), // 7: planet.v1.GetBudgetResponse - (*MapDensityRequest)(nil), // 8: planet.v1.MapDensityRequest - (*MapDensityResponse)(nil), // 9: planet.v1.MapDensityResponse - (*GetMapRequest)(nil), // 10: planet.v1.GetMapRequest - (*GetMapResponse)(nil), // 11: planet.v1.GetMapResponse - (*TileShields)(nil), // 12: planet.v1.TileShields - (*ListenForEventsRequest)(nil), // 13: planet.v1.ListenForEventsRequest - (*PlanetEvent)(nil), // 14: planet.v1.PlanetEvent - (*GetFortressesRequest)(nil), // 15: planet.v1.GetFortressesRequest - (*GetFortressesResponse)(nil), // 16: planet.v1.GetFortressesResponse - (*Fortress)(nil), // 17: planet.v1.Fortress - (*LandmassFortified)(nil), // 18: planet.v1.LandmassFortified - (*ChargesHeld)(nil), // 19: planet.v1.ChargesHeld - (*GetChargesRequest)(nil), // 20: planet.v1.GetChargesRequest - (*GetChargesResponse)(nil), // 21: planet.v1.GetChargesResponse - (*GetBonusRulesRequest)(nil), // 22: planet.v1.GetBonusRulesRequest - (*GetBonusRulesResponse)(nil), // 23: planet.v1.GetBonusRulesResponse - (*TollStep)(nil), // 24: planet.v1.TollStep - (*BonusOffered)(nil), // 25: planet.v1.BonusOffered - (*BonusTaken)(nil), // 26: planet.v1.BonusTaken - (*ClaimBonusRequest)(nil), // 27: planet.v1.ClaimBonusRequest - (*ClaimBonusResponse)(nil), // 28: planet.v1.ClaimBonusResponse - (*QuizOffered)(nil), // 29: planet.v1.QuizOffered - (*OpenQuizRequest)(nil), // 30: planet.v1.OpenQuizRequest - (*OpenQuizResponse)(nil), // 31: planet.v1.OpenQuizResponse - (*AnswerQuizRequest)(nil), // 32: planet.v1.AnswerQuizRequest - (*AnswerQuizResponse)(nil), // 33: planet.v1.AnswerQuizResponse - (*UseRefillRequest)(nil), // 34: planet.v1.UseRefillRequest - (*UseRefillResponse)(nil), // 35: planet.v1.UseRefillResponse - (*GlobePoint)(nil), // 36: planet.v1.GlobePoint - (*DropBombRequest)(nil), // 37: planet.v1.DropBombRequest - (*DropBombResponse)(nil), // 38: planet.v1.DropBombResponse - (*BombDropped)(nil), // 39: planet.v1.BombDropped - (*TilesEnclosed)(nil), // 40: planet.v1.TilesEnclosed - (*TilesSpread)(nil), // 41: planet.v1.TilesSpread - (*PlaceShieldRequest)(nil), // 42: planet.v1.PlaceShieldRequest - (*PlaceShieldResponse)(nil), // 43: planet.v1.PlaceShieldResponse - (*Heartbeat)(nil), // 44: planet.v1.Heartbeat - (*TileUpdate)(nil), // 45: planet.v1.TileUpdate + (*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 + (*BanRefusal)(nil), // 8: planet.v1.BanRefusal + (*GetBudgetRequest)(nil), // 9: planet.v1.GetBudgetRequest + (*GetBudgetResponse)(nil), // 10: planet.v1.GetBudgetResponse + (*MapDensityRequest)(nil), // 11: planet.v1.MapDensityRequest + (*MapDensityResponse)(nil), // 12: planet.v1.MapDensityResponse + (*GetMapRequest)(nil), // 13: planet.v1.GetMapRequest + (*GetMapResponse)(nil), // 14: planet.v1.GetMapResponse + (*TileShields)(nil), // 15: planet.v1.TileShields + (*ListenForEventsRequest)(nil), // 16: planet.v1.ListenForEventsRequest + (*PlanetEvent)(nil), // 17: planet.v1.PlanetEvent + (*GetFortressesRequest)(nil), // 18: planet.v1.GetFortressesRequest + (*GetFortressesResponse)(nil), // 19: planet.v1.GetFortressesResponse + (*Fortress)(nil), // 20: planet.v1.Fortress + (*LandmassFortified)(nil), // 21: planet.v1.LandmassFortified + (*ChargesHeld)(nil), // 22: planet.v1.ChargesHeld + (*GetChargesRequest)(nil), // 23: planet.v1.GetChargesRequest + (*GetChargesResponse)(nil), // 24: planet.v1.GetChargesResponse + (*GetBonusRulesRequest)(nil), // 25: planet.v1.GetBonusRulesRequest + (*GetBonusRulesResponse)(nil), // 26: planet.v1.GetBonusRulesResponse + (*TollStep)(nil), // 27: planet.v1.TollStep + (*BonusOffered)(nil), // 28: planet.v1.BonusOffered + (*BonusTaken)(nil), // 29: planet.v1.BonusTaken + (*ClaimBonusRequest)(nil), // 30: planet.v1.ClaimBonusRequest + (*ClaimBonusResponse)(nil), // 31: planet.v1.ClaimBonusResponse + (*QuizOffered)(nil), // 32: planet.v1.QuizOffered + (*OpenQuizRequest)(nil), // 33: planet.v1.OpenQuizRequest + (*OpenQuizResponse)(nil), // 34: planet.v1.OpenQuizResponse + (*AnswerQuizRequest)(nil), // 35: planet.v1.AnswerQuizRequest + (*AnswerQuizResponse)(nil), // 36: planet.v1.AnswerQuizResponse + (*UseRefillRequest)(nil), // 37: planet.v1.UseRefillRequest + (*UseRefillResponse)(nil), // 38: planet.v1.UseRefillResponse + (*GlobePoint)(nil), // 39: planet.v1.GlobePoint + (*DropBombRequest)(nil), // 40: planet.v1.DropBombRequest + (*DropBombResponse)(nil), // 41: planet.v1.DropBombResponse + (*BombDropped)(nil), // 42: planet.v1.BombDropped + (*TilesEnclosed)(nil), // 43: planet.v1.TilesEnclosed + (*TilesSpread)(nil), // 44: planet.v1.TilesSpread + (*PlaceShieldRequest)(nil), // 45: planet.v1.PlaceShieldRequest + (*PlaceShieldResponse)(nil), // 46: planet.v1.PlaceShieldResponse + (*Heartbeat)(nil), // 47: planet.v1.Heartbeat + (*TileUpdate)(nil), // 48: 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 - 12, // 3: planet.v1.GetMapResponse.shields:type_name -> planet.v1.TileShields - 45, // 4: planet.v1.PlanetEvent.tile_update:type_name -> planet.v1.TileUpdate - 44, // 5: planet.v1.PlanetEvent.heartbeat:type_name -> planet.v1.Heartbeat - 25, // 6: planet.v1.PlanetEvent.bonus_offered:type_name -> planet.v1.BonusOffered - 26, // 7: planet.v1.PlanetEvent.bonus_taken:type_name -> planet.v1.BonusTaken - 39, // 8: planet.v1.PlanetEvent.bomb_dropped:type_name -> planet.v1.BombDropped - 40, // 9: planet.v1.PlanetEvent.tiles_enclosed:type_name -> planet.v1.TilesEnclosed - 41, // 10: planet.v1.PlanetEvent.tiles_spread:type_name -> planet.v1.TilesSpread - 29, // 11: planet.v1.PlanetEvent.quiz_offered:type_name -> planet.v1.QuizOffered - 18, // 12: planet.v1.PlanetEvent.landmass_fortified:type_name -> planet.v1.LandmassFortified - 17, // 13: planet.v1.GetFortressesResponse.fortresses:type_name -> planet.v1.Fortress - 19, // 14: planet.v1.GetChargesResponse.charges:type_name -> planet.v1.ChargesHeld - 24, // 15: planet.v1.GetBonusRulesResponse.toll_steps:type_name -> planet.v1.TollStep - 1, // 16: planet.v1.BonusOffered.kind:type_name -> planet.v1.BonusKind - 1, // 17: planet.v1.BonusTaken.kind:type_name -> planet.v1.BonusKind - 1, // 18: planet.v1.ClaimBonusResponse.kind:type_name -> planet.v1.BonusKind - 19, // 19: planet.v1.ClaimBonusResponse.charges:type_name -> planet.v1.ChargesHeld - 1, // 20: planet.v1.AnswerQuizResponse.kind:type_name -> planet.v1.BonusKind - 19, // 21: planet.v1.AnswerQuizResponse.charges:type_name -> planet.v1.ChargesHeld - 2, // 22: planet.v1.UseRefillResponse.budget:type_name -> planet.v1.ClickBudget - 19, // 23: planet.v1.UseRefillResponse.charges:type_name -> planet.v1.ChargesHeld - 36, // 24: planet.v1.DropBombRequest.target:type_name -> planet.v1.GlobePoint - 36, // 25: planet.v1.BombDropped.point:type_name -> planet.v1.GlobePoint - 19, // 26: planet.v1.PlaceShieldResponse.charges:type_name -> planet.v1.ChargesHeld - 3, // 27: planet.v1.ClickService.Click:input_type -> planet.v1.ClickRequest - 6, // 28: planet.v1.ClickService.GetBudget:input_type -> planet.v1.GetBudgetRequest - 8, // 29: planet.v1.ClickService.MapDensity:input_type -> planet.v1.MapDensityRequest - 10, // 30: planet.v1.ClickService.GetMap:input_type -> planet.v1.GetMapRequest - 13, // 31: planet.v1.ClickService.ListenForEvents:input_type -> planet.v1.ListenForEventsRequest - 27, // 32: planet.v1.ClickService.ClaimBonus:input_type -> planet.v1.ClaimBonusRequest - 37, // 33: planet.v1.ClickService.DropBomb:input_type -> planet.v1.DropBombRequest - 34, // 34: planet.v1.ClickService.UseRefill:input_type -> planet.v1.UseRefillRequest - 20, // 35: planet.v1.ClickService.GetCharges:input_type -> planet.v1.GetChargesRequest - 22, // 36: planet.v1.ClickService.GetBonusRules:input_type -> planet.v1.GetBonusRulesRequest - 30, // 37: planet.v1.ClickService.OpenQuiz:input_type -> planet.v1.OpenQuizRequest - 32, // 38: planet.v1.ClickService.AnswerQuiz:input_type -> planet.v1.AnswerQuizRequest - 42, // 39: planet.v1.ClickService.PlaceShield:input_type -> planet.v1.PlaceShieldRequest - 15, // 40: planet.v1.ClickService.GetFortresses:input_type -> planet.v1.GetFortressesRequest - 4, // 41: planet.v1.ClickService.Click:output_type -> planet.v1.ClickResponse - 7, // 42: planet.v1.ClickService.GetBudget:output_type -> planet.v1.GetBudgetResponse - 9, // 43: planet.v1.ClickService.MapDensity:output_type -> planet.v1.MapDensityResponse - 11, // 44: planet.v1.ClickService.GetMap:output_type -> planet.v1.GetMapResponse - 14, // 45: planet.v1.ClickService.ListenForEvents:output_type -> planet.v1.PlanetEvent - 28, // 46: planet.v1.ClickService.ClaimBonus:output_type -> planet.v1.ClaimBonusResponse - 38, // 47: planet.v1.ClickService.DropBomb:output_type -> planet.v1.DropBombResponse - 35, // 48: planet.v1.ClickService.UseRefill:output_type -> planet.v1.UseRefillResponse - 21, // 49: planet.v1.ClickService.GetCharges:output_type -> planet.v1.GetChargesResponse - 23, // 50: planet.v1.ClickService.GetBonusRules:output_type -> planet.v1.GetBonusRulesResponse - 31, // 51: planet.v1.ClickService.OpenQuiz:output_type -> planet.v1.OpenQuizResponse - 33, // 52: planet.v1.ClickService.AnswerQuiz:output_type -> planet.v1.AnswerQuizResponse - 43, // 53: planet.v1.ClickService.PlaceShield:output_type -> planet.v1.PlaceShieldResponse - 16, // 54: planet.v1.ClickService.GetFortresses:output_type -> planet.v1.GetFortressesResponse - 41, // [41:55] is the sub-list for method output_type - 27, // [27:41] is the sub-list for method input_type - 27, // [27:27] is the sub-list for extension type_name - 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 + 15, // 4: planet.v1.GetMapResponse.shields:type_name -> planet.v1.TileShields + 48, // 5: planet.v1.PlanetEvent.tile_update:type_name -> planet.v1.TileUpdate + 47, // 6: planet.v1.PlanetEvent.heartbeat:type_name -> planet.v1.Heartbeat + 28, // 7: planet.v1.PlanetEvent.bonus_offered:type_name -> planet.v1.BonusOffered + 29, // 8: planet.v1.PlanetEvent.bonus_taken:type_name -> planet.v1.BonusTaken + 42, // 9: planet.v1.PlanetEvent.bomb_dropped:type_name -> planet.v1.BombDropped + 43, // 10: planet.v1.PlanetEvent.tiles_enclosed:type_name -> planet.v1.TilesEnclosed + 44, // 11: planet.v1.PlanetEvent.tiles_spread:type_name -> planet.v1.TilesSpread + 32, // 12: planet.v1.PlanetEvent.quiz_offered:type_name -> planet.v1.QuizOffered + 21, // 13: planet.v1.PlanetEvent.landmass_fortified:type_name -> planet.v1.LandmassFortified + 20, // 14: planet.v1.GetFortressesResponse.fortresses:type_name -> planet.v1.Fortress + 22, // 15: planet.v1.GetChargesResponse.charges:type_name -> planet.v1.ChargesHeld + 27, // 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 + 22, // 20: planet.v1.ClaimBonusResponse.charges:type_name -> planet.v1.ChargesHeld + 1, // 21: planet.v1.AnswerQuizResponse.kind:type_name -> planet.v1.BonusKind + 22, // 22: planet.v1.AnswerQuizResponse.charges:type_name -> planet.v1.ChargesHeld + 5, // 23: planet.v1.UseRefillResponse.budget:type_name -> planet.v1.ClickBudget + 22, // 24: planet.v1.UseRefillResponse.charges:type_name -> planet.v1.ChargesHeld + 39, // 25: planet.v1.DropBombRequest.target:type_name -> planet.v1.GlobePoint + 39, // 26: planet.v1.BombDropped.point:type_name -> planet.v1.GlobePoint + 22, // 27: planet.v1.PlaceShieldResponse.charges:type_name -> planet.v1.ChargesHeld + 6, // 28: planet.v1.ClickService.Click:input_type -> planet.v1.ClickRequest + 9, // 29: planet.v1.ClickService.GetBudget:input_type -> planet.v1.GetBudgetRequest + 11, // 30: planet.v1.ClickService.MapDensity:input_type -> planet.v1.MapDensityRequest + 13, // 31: planet.v1.ClickService.GetMap:input_type -> planet.v1.GetMapRequest + 16, // 32: planet.v1.ClickService.ListenForEvents:input_type -> planet.v1.ListenForEventsRequest + 30, // 33: planet.v1.ClickService.ClaimBonus:input_type -> planet.v1.ClaimBonusRequest + 40, // 34: planet.v1.ClickService.DropBomb:input_type -> planet.v1.DropBombRequest + 37, // 35: planet.v1.ClickService.UseRefill:input_type -> planet.v1.UseRefillRequest + 23, // 36: planet.v1.ClickService.GetCharges:input_type -> planet.v1.GetChargesRequest + 25, // 37: planet.v1.ClickService.GetBonusRules:input_type -> planet.v1.GetBonusRulesRequest + 33, // 38: planet.v1.ClickService.OpenQuiz:input_type -> planet.v1.OpenQuizRequest + 35, // 39: planet.v1.ClickService.AnswerQuiz:input_type -> planet.v1.AnswerQuizRequest + 45, // 40: planet.v1.ClickService.PlaceShield:input_type -> planet.v1.PlaceShieldRequest + 18, // 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 + 10, // 44: planet.v1.ClickService.GetBudget:output_type -> planet.v1.GetBudgetResponse + 12, // 45: planet.v1.ClickService.MapDensity:output_type -> planet.v1.MapDensityResponse + 14, // 46: planet.v1.ClickService.GetMap:output_type -> planet.v1.GetMapResponse + 17, // 47: planet.v1.ClickService.ListenForEvents:output_type -> planet.v1.PlanetEvent + 31, // 48: planet.v1.ClickService.ClaimBonus:output_type -> planet.v1.ClaimBonusResponse + 41, // 49: planet.v1.ClickService.DropBomb:output_type -> planet.v1.DropBombResponse + 38, // 50: planet.v1.ClickService.UseRefill:output_type -> planet.v1.UseRefillResponse + 24, // 51: planet.v1.ClickService.GetCharges:output_type -> planet.v1.GetChargesResponse + 26, // 52: planet.v1.ClickService.GetBonusRules:output_type -> planet.v1.GetBonusRulesResponse + 34, // 53: planet.v1.ClickService.OpenQuiz:output_type -> planet.v1.OpenQuizResponse + 36, // 54: planet.v1.ClickService.AnswerQuiz:output_type -> planet.v1.AnswerQuizResponse + 46, // 55: planet.v1.ClickService.PlaceShield:output_type -> planet.v1.PlaceShieldResponse + 19, // 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() } @@ -3075,7 +3227,7 @@ func file_planet_v1_planet_proto_init() { if File_planet_v1_planet_proto != nil { return } - file_planet_v1_planet_proto_msgTypes[12].OneofWrappers = []any{ + file_planet_v1_planet_proto_msgTypes[15].OneofWrappers = []any{ (*PlanetEvent_TileUpdate)(nil), (*PlanetEvent_Heartbeat)(nil), (*PlanetEvent_BonusOffered)(nil), @@ -3092,7 +3244,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: 44, + NumMessages: 47, 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/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..e0c2eb91f --- /dev/null +++ b/apps/backend/internal/planet/internal/allegiances/book.go @@ -0,0 +1,44 @@ +package allegiances + +import ( + "context" + "fmt" + + "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/clicks" + "github.com/raphoester/clickplanet.lol-backend/internal/shared/cptime" +) + +type Store interface { + // 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 { + 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(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(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) + } + + 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..57ed0591c --- /dev/null +++ b/apps/backend/internal/planet/internal/allegiances/book_test.go @@ -0,0 +1,83 @@ +package allegiances_test + +import ( + "errors" + "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/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() (allegiances.Book, *allegiances.MemoryStore, *cptime.FixedClock) { + store := allegiances.NewMemoryStore() + clock := cptime.NewFixedClock(at) + + return allegiances.NewBook(store, cpcountries.New(), clock), store, clock +} + +func TestAnAccountIsHeldToItsFlagForAnHourAfterAChange(t *testing.T) { + b, store, clock := book() + payer := clicks.Payer{Account: alice} + + 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(t.Context(), payer, "fr") + require.ErrorIs(t, err, allegiances.ErrChangedTooSoon) + assert.Equal(t, allegiances.Snapshot{Flag: "de", ChangedAt: at}, store.Kept(alice)) + + clock.Advance(time.Minute) + 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, store, _ := book() + + 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", store.Kept(alice).Flag) + assert.Equal(t, "fr", store.Kept(bob).Flag) +} + +func TestACallerWithNoAccountIsHeldToNothing(t *testing.T) { + b, store, _ := book() + + 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{}, store.Kept(allegiances.NoAccount)) +} + +func TestAFlagThatIsNoCountryIsNotKept(t *testing.T) { + b, store, _ := book() + payer := clicks.Payer{Account: alice} + 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) +} + +func TestAStoreThatFailsFailsThePledge(t *testing.T) { + b, store, _ := book() + down := errors.New("postgres is down") + store.FailWith(down) + + assert.ErrorIs(t, b.Pledge(t.Context(), clicks.Payer{Account: alice}, "fr"), down) +} 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/postgres_allegiance_store/store.go b/apps/backend/internal/planet/internal/allegiances/postgres_allegiance_store/store.go new file mode 100644 index 000000000..48d0bbe24 --- /dev/null +++ b/apps/backend/internal/planet/internal/allegiances/postgres_allegiance_store/store.go @@ -0,0 +1,85 @@ +package postgres_allegiance_store + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/allegiances" + "github.com/raphoester/clickplanet.lol-backend/internal/shared/cppg" +) + +const allegianceLock = 0x616c6c65 + +func New(db cppg.Beginner) *Store { + return &Store{db: db} +} + +type Store struct { + db cppg.Beginner +} + +var _ allegiances.Store = (*Store)(nil) + +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 begin changing the allegiance: %w", err) + } + defer func() { + if err != nil { + _ = tx.Rollback() + } + }() + + 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) + } + + before, err := keptOf(ctx, tx, account) + if err != nil { + return err + } + + 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), 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 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 new file mode 100644 index 000000000..ec516dee5 --- /dev/null +++ b/apps/backend/internal/planet/internal/allegiances/postgres_allegiance_store/store_test.go @@ -0,0 +1,43 @@ +package postgres_allegiance_store_test + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/require" + "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 TestThePostgresStoreKeepsTheContract(t *testing.T) { + db := cppg.StartTestServer(t).OpenSchema(t, "planet", migrations.FS) + + 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)) + }, + }) +} + +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" + + require.NoError(t, store.Change(t.Context(), alice, func(allegiances.Allegiance) (allegiances.Allegiance, error) { + return allegiances.Snapshot{Flag: "fr"}.Restore(), nil + })) + + 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 6d5267525..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 @@ -27,20 +27,25 @@ type CountryChecker interface { CheckCountry(country string) bool } +type Allegiances interface { + Pledge(ctx context.Context, payer clicks.Payer, flag string) error +} + type In struct { TileID uint32 CountryID string } -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) { @@ -57,7 +62,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(ctx, 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 9b0f4351d..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 @@ -4,10 +4,12 @@ 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/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 +17,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 +25,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 +43,15 @@ 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(allegiances.NewMemoryStore(), 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), } } @@ -120,10 +130,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..5b42f732f --- /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(ctx context.Context, 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(ctx, 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..84bcd809d --- /dev/null +++ b/apps/backend/internal/planet/internal/clicks/usecases/click_usecase/allegiance_click/usecase_test.go @@ -0,0 +1,65 @@ +package allegiance_click_test + +import ( + "context" + "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/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() + + clock := cptime.NewFixedClock(time.Date(2026, 10, 17, 12, 0, 0, 0, time.UTC)) + inner := &countingClicks{} + + 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 { + 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 bfc280fa2..88d142d6c 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" "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/planetv1controller/planetban" ) @@ -25,6 +26,10 @@ func toConnect(err error, out click_usecase.Out) error { return refused } + 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_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 new file mode 100644 index 000000000..693ef3330 --- /dev/null +++ b/apps/backend/internal/planet/internal/planetv1controller/get_allegiance_handler/handler.go @@ -0,0 +1,34 @@ +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(ctx context.Context, account allegiances.Account) (*planetv1.GetAllegianceResponse, error) +} + +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) { + 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/internal/planetv1controller/place_shield_handler/handler.go b/apps/backend/internal/planet/internal/planetv1controller/place_shield_handler/handler.go index 09ef97826..5fb232484 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" "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/planetv1controller/planetban" ) @@ -39,6 +40,10 @@ func (h PlaceShieldHandler) PlaceShield( return nil, refused } + 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 b8e9f9dd2..2d8441a66 100644 --- a/apps/backend/internal/planet/module.go +++ b/apps/backend/internal/planet/module.go @@ -13,6 +13,8 @@ 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/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 +38,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/ban_click" @@ -79,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" @@ -190,6 +195,8 @@ func NewModule(config Config) cpbootstrap.Module { pricer := clicks.NewToll(config.Toll, tilesStorage) + book := allegiances.NewBook(postgres_allegiance_store.New(db), countries, clock) + shielding := clicks.NewShielding(tilesStorage) writer := ledger.NewRecording(tilesStorage, clicks.NewClaiming(tilesStorage, config.Bonus.ShieldsPerTile()), @@ -238,6 +245,9 @@ func NewModule(config Config) cpbootstrap.Module { 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) // Outside the throttle: a refused click must not spend a token, or the client shows a 429. @@ -371,8 +381,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.NewPostgresQuery(db)), } return props.RPC.Mount(func(options ...connect.HandlerOption) (string, http.Handler) { 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 */ diff --git a/apps/frontend/CLAUDE.md b/apps/frontend/CLAUDE.md index 404e13484..4e515b0d2 100644 --- a/apps/frontend/CLAUDE.md +++ b/apps/frontend/CLAUDE.md @@ -64,7 +64,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. `fakeBackend.ban(90)` refuses this player's clicks, bombs and shields for 90 @@ -2464,6 +2465,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 14e3d891b..80781f4c8 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, @@ -40,7 +41,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() @@ -249,6 +259,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 657f443c7..295eb9854 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, reportClaimFailure, reportClickFailure, spinStep} from "./globe.ts" -import {BannedError, BonusLostError, RateLimitedError, VPNBlockedError} from "../../backends/backend.ts" +import {AllegianceLockedError, BannedError, BonusLostError, RateLimitedError, VPNBlockedError} from "../../backends/backend.ts" import {SessionUnavailableError} from "../../backends/session.ts" import {OwnerChange, TileOwnership} from "../../domain/tileOwnership.ts" import {LeaderboardEntry, rankCountries} from "../../domain/leaderboard.ts" @@ -55,6 +55,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 729189fd5..2520932a5 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, BannedError, BombDrop, Bomber, @@ -545,7 +546,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, onBanned}) }) } @@ -1076,7 +1077,7 @@ export function reportClickFailure( else if (error instanceof VPNBlockedError) handlers.onVPNBlocked() else if (error instanceof BannedError) handlers.onBanned(error.until) 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 c5a9143fe..d6c1e4810 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 606283385..b59b8932e 100644 --- a/apps/frontend/src/backends/fakeBackend.ts +++ b/apps/frontend/src/backends/fakeBackend.ts @@ -1,4 +1,6 @@ import { + AllegianceLockedError, + AllegianceSource, BankFullError, BannedError, BombDrop, @@ -25,6 +27,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'; @@ -37,6 +40,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}, @@ -100,7 +105,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 = "" @@ -116,6 +121,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 @@ -218,6 +225,7 @@ export class FakeBackend implements TileClicker, OwnershipsGetter, UpdatesListen if (this.sessionUnavailable) throw new SessionUnavailableError() this.refuseBanned() if (this.vpnBlocked) throw new VPNBlockedError() + this.pledge(countryId) const allowed = this.allow(countryId) this.reportBudget() @@ -332,6 +340,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 ban(minutes = 60): void { this.bannedUntil = minutes > 0 ? Date.now() + minutes * 60_000 : undefined } @@ -450,6 +484,7 @@ export class FakeBackend implements TileClicker, OwnershipsGetter, UpdatesListen public async placeShield(tileId: number, countryId: string): Promise { if (this.sessionUnavailable) throw new SessionUnavailableError() this.refuseBanned() + 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 ed8f81691..da41b9469 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, BanRefusal, BombDropped, BonusKind, @@ -23,6 +24,7 @@ import { QuizOffered, ChargesHeld, ClickBudget as ClickBudgetMessage, + GetAllegianceResponse, GetBonusRulesResponse, GetMapResponse, GlobePoint, @@ -35,10 +37,11 @@ import { TilesSpread, TileUpdate, } from "../gen/grpc/planet/v1/planet_pb.ts" -import {BankFullError, BannedError, BonusLostError, ShieldRefusedError, RateLimitedError, VPNBlockedError} from "./backend.ts" +import {AllegianceLockedError, BankFullError, BannedError, BonusLostError, ShieldRefusedError, RateLimitedError, VPNBlockedError} from "./backend.ts" import {SESSION_HEADER, type SessionProvider, SessionUnavailableError} from "./session.ts" import type {ClickBudget} from "./clickBudget.ts" import type {BonusRules, Charges} from "../domain/bonus.ts" +import type {Allegiance} from "../domain/allegiance.ts" function fixedSession(token: string): SessionProvider { return {token: async () => token, held: () => token, identity: async () => token, heldIdentity: () => token, invalidate: () => {}} @@ -175,6 +178,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()), } } @@ -1500,3 +1504,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 f18c4e36d..87c45b101 100644 --- a/apps/frontend/src/backends/planetBackend.ts +++ b/apps/frontend/src/backends/planetBackend.ts @@ -1,4 +1,6 @@ import { + AllegianceLockedError, + AllegianceSource, BankFullError, BannedError, BombDrop, @@ -27,7 +29,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, BanRefusal, BonusKind, ChargesHeld, @@ -56,7 +60,7 @@ export function newClickServiceClient(config: Config): PromiseClient void>() private readonly updateCallbacks = new Map void>() @@ -66,6 +70,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 @@ -81,6 +86,8 @@ export class PlanetBackend implements TileClicker, OwnershipsGetter, UpdatesList private charges: Charges = NO_CHARGES + private allegiance: Allegiance | undefined + constructor( private client: PromiseClient, batchUpdateDurationMs: number, @@ -117,6 +124,7 @@ export class PlanetBackend implements TileClicker, OwnershipsGetter, UpdatesList this.budgetCallbacks.clear() this.resumeCallbacks.clear() this.fortificationCallbacks.clear() + this.allegianceCallbacks.clear() this.pendingUpdates = [] } @@ -160,8 +168,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 } } @@ -337,7 +347,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 { @@ -348,6 +358,7 @@ export class PlanetBackend implements TileClicker, OwnershipsGetter, UpdatesList this.stopListening = this.openEventStream() void this.readCharges(token) + void this.readAllegiance(token) } private async readRules(): Promise { @@ -389,6 +400,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) @@ -628,9 +671,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( @@ -770,6 +818,8 @@ function bannedOf(e: ConnectError): BannedError | undefined { } 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) @@ -796,6 +846,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 @@ -805,6 +868,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 bannedOf(e) ?? 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 2106a75ed..c785f3ee7 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 7afefd198..7ece2054a 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 {