Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/backend/.golangci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ linters:
- cpbootstrap.Run(

- .Click(
- .Pledge(
- .GetMap(
- .SendMessage(
- .ListenForEvents(
Expand Down
46 changes: 44 additions & 2 deletions apps/backend/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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.

Expand Down Expand Up @@ -726,6 +728,7 @@ POST /planet.v1.ClickService/Click [X-Session-Token: <the minted 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)
Expand Down Expand Up @@ -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.
Expand Down
51 changes: 51 additions & 0 deletions apps/backend/e2e/allegiance_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
5 changes: 3 additions & 2 deletions apps/backend/e2e/fortify_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
19 changes: 9 additions & 10 deletions apps/backend/e2e/player_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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()}})
Expand All @@ -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)
Expand Down
Loading
Loading