package domain import ( "cmp" "fmt" "math" "net/url" "slices" "strings" "time" ) const ( playerNameMinLength = 1 playerNameMaxLength = 150 ) type Player struct { id int serverKey string name string numVillages int points int rank int tribeID int od OpponentsDefeated profileURL *url.URL bestRank int bestRankAt time.Time mostPoints int mostPointsAt time.Time mostVillages int mostVillagesAt time.Time lastActivityAt time.Time createdAt time.Time deletedAt time.Time } const playerModelName = "Player" // UnmarshalPlayerFromDatabase unmarshals Player from the database. // // It should be used only for unmarshalling from the database! // You can't use UnmarshalPlayerFromDatabase as constructor - It may put domain into the invalid state! func UnmarshalPlayerFromDatabase( id int, serverKey string, name string, numVillages int, points int, rank int, tribeID int, od OpponentsDefeated, rawProfileURL string, bestRank int, bestRankAt time.Time, mostPoints int, mostPointsAt time.Time, mostVillages int, mostVillagesAt time.Time, lastActivityAt time.Time, createdAt time.Time, deletedAt time.Time, ) (Player, error) { if err := validateIntInRange(id, 1, math.MaxInt); err != nil { return Player{}, ValidationError{ Model: playerModelName, Field: "id", Err: err, } } if err := validateServerKey(serverKey); err != nil { return Player{}, ValidationError{ Model: playerModelName, Field: "serverKey", Err: err, } } profileURL, err := parseURL(rawProfileURL) if err != nil { return Player{}, ValidationError{ Model: playerModelName, Field: "profileURL", Err: err, } } return Player{ id: id, serverKey: serverKey, name: name, numVillages: numVillages, points: points, rank: rank, tribeID: tribeID, od: od, profileURL: profileURL, bestRank: bestRank, bestRankAt: bestRankAt, mostPoints: mostPoints, mostPointsAt: mostPointsAt, mostVillages: mostVillages, mostVillagesAt: mostVillagesAt, lastActivityAt: lastActivityAt, createdAt: createdAt, deletedAt: deletedAt, }, nil } func (p Player) ID() int { return p.id } func (p Player) ServerKey() string { return p.serverKey } func (p Player) Name() string { return p.name } func (p Player) NumVillages() int { return p.numVillages } func (p Player) Points() int { return p.points } func (p Player) Rank() int { return p.rank } func (p Player) TribeID() int { return p.tribeID } func (p Player) OD() OpponentsDefeated { return p.od } func (p Player) ProfileURL() *url.URL { return p.profileURL } func (p Player) BestRank() int { return p.bestRank } func (p Player) BestRankAt() time.Time { return p.bestRankAt } func (p Player) MostPoints() int { return p.mostPoints } func (p Player) MostPointsAt() time.Time { return p.mostPointsAt } func (p Player) MostVillages() int { return p.mostVillages } func (p Player) MostVillagesAt() time.Time { return p.mostVillagesAt } func (p Player) LastActivityAt() time.Time { return p.lastActivityAt } func (p Player) CreatedAt() time.Time { return p.createdAt } func (p Player) DeletedAt() time.Time { return p.deletedAt } func (p Player) WithRelations(tribe NullTribeMeta) PlayerWithRelations { return PlayerWithRelations{ player: p, tribe: tribe, } } func (p Player) ToCursor() (PlayerCursor, error) { return NewPlayerCursor( p.id, p.serverKey, p.od.scoreAtt, p.od.scoreDef, p.od.scoreSup, p.od.scoreTotal, p.points, p.deletedAt, ) } func (p Player) Meta() PlayerMeta { return PlayerMeta{ id: p.id, name: p.name, profileURL: p.profileURL, } } func (p Player) Base() BasePlayer { return BasePlayer{ id: p.id, name: p.name, numVillages: p.numVillages, points: p.points, rank: p.rank, tribeID: p.tribeID, od: p.od, profileURL: p.profileURL, } } func (p Player) IsDeleted() bool { return !p.deletedAt.IsZero() } func (p Player) IsZero() bool { return p == Player{} } type Players []Player // Delete finds all players with the given serverKey that are not in the given slice with active players // and returns their ids + tribe changes that must be created. // Both slices must be sorted in ascending order by ID. // + if ps contains players from different servers. they must be sorted in ascending order by server key. func (ps Players) Delete(serverKey string, active BasePlayers) ([]int, []CreateTribeChangeParams, error) { // players are deleted now and then, there is no point in prereallocating these slices //nolint:prealloc var toDelete []int var params []CreateTribeChangeParams for _, p := range ps { if p.IsDeleted() || p.ServerKey() != serverKey { continue } _, found := slices.BinarySearchFunc(active, p, func(a BasePlayer, b Player) int { return cmp.Compare(a.ID(), b.ID()) }) if found { continue } toDelete = append(toDelete, p.ID()) if p.TribeID() > 0 { p, err := NewCreateTribeChangeParams(serverKey, p.ID(), p.TribeID(), 0) if err != nil { return nil, nil, err } params = append(params, p) } } return toDelete, params, nil } type PlayerWithRelations struct { player Player tribe NullTribeMeta } func (p PlayerWithRelations) Player() Player { return p.player } func (p PlayerWithRelations) Tribe() NullTribeMeta { return p.tribe } func (p PlayerWithRelations) Meta() PlayerMetaWithRelations { return PlayerMetaWithRelations{ player: p.player.Meta(), tribe: p.tribe, } } func (p PlayerWithRelations) IsZero() bool { return p.player.IsZero() } type PlayersWithRelations []PlayerWithRelations type PlayerMeta struct { id int name string profileURL *url.URL } const playerMetaModelName = "PlayerMeta" // UnmarshalPlayerMetaFromDatabase unmarshals PlayerMeta from the database. // // It should be used only for unmarshalling from the database! // You can't use UnmarshalPlayerMetaFromDatabase as constructor - It may put domain into the invalid state! func UnmarshalPlayerMetaFromDatabase(id int, name string, rawProfileURL string) (PlayerMeta, error) { if err := validateIntInRange(id, 1, math.MaxInt); err != nil { return PlayerMeta{}, ValidationError{ Model: playerMetaModelName, Field: "id", Err: err, } } profileURL, err := parseURL(rawProfileURL) if err != nil { return PlayerMeta{}, ValidationError{ Model: playerMetaModelName, Field: "profileURL", Err: err, } } return PlayerMeta{id: id, name: name, profileURL: profileURL}, nil } func (p PlayerMeta) ID() int { return p.id } func (p PlayerMeta) Name() string { return p.name } func (p PlayerMeta) ProfileURL() *url.URL { return p.profileURL } func (p PlayerMeta) IsZero() bool { return p == PlayerMeta{} } func (p PlayerMeta) WithRelations(tribe NullTribeMeta) PlayerMetaWithRelations { return PlayerMetaWithRelations{ player: p, tribe: tribe, } } type NullPlayerMeta NullValue[PlayerMeta] func (p NullPlayerMeta) WithRelations(tribe NullTribeMeta) NullPlayerMetaWithRelations { return NullPlayerMetaWithRelations{ V: PlayerMetaWithRelations{ player: p.V, tribe: tribe, }, Valid: !p.IsZero(), } } func (p NullPlayerMeta) IsZero() bool { return !p.Valid } type PlayerMetaWithRelations struct { player PlayerMeta tribe NullTribeMeta } func (p PlayerMetaWithRelations) Player() PlayerMeta { return p.player } func (p PlayerMetaWithRelations) Tribe() NullTribeMeta { return p.tribe } func (p PlayerMetaWithRelations) IsZero() bool { return p.player.IsZero() } type NullPlayerMetaWithRelations NullValue[PlayerMetaWithRelations] func (p NullPlayerMetaWithRelations) IsZero() bool { return !p.Valid } type CreatePlayerParams struct { base BasePlayer serverKey string bestRank int bestRankAt time.Time mostPoints int mostPointsAt time.Time mostVillages int mostVillagesAt time.Time lastActivityAt time.Time } const createPlayerParamsModelName = "CreatePlayerParams" // NewCreatePlayerParams constructs a slice of CreatePlayerParams based on the given parameters. // Both slices must be sorted in ascending order by ID // + if storedPlayers contains players from different servers. they must be sorted in ascending order by server key. // //nolint:gocyclo func NewCreatePlayerParams(serverKey string, players BasePlayers, storedPlayers Players) ([]CreatePlayerParams, error) { if err := validateServerKey(serverKey); err != nil { return nil, ValidationError{ Model: createPlayerParamsModelName, Field: "serverKey", Err: err, } } params := make([]CreatePlayerParams, 0, len(players)) for i, player := range players { if player.IsZero() { return nil, fmt.Errorf("players[%d] is an empty struct", i) } var old Player idx, found := slices.BinarySearchFunc(storedPlayers, player, func(a Player, b BasePlayer) int { return cmp.Or( cmp.Compare(a.ServerKey(), serverKey), cmp.Compare(a.ID(), b.ID()), ) }) if found { old = storedPlayers[idx] } now := time.Now() p := CreatePlayerParams{ base: player, serverKey: serverKey, bestRank: player.Rank(), bestRankAt: now, mostPoints: player.Points(), mostPointsAt: now, mostVillages: player.NumVillages(), mostVillagesAt: now, lastActivityAt: now, } if old.ID() > 0 { if old.MostPoints() >= p.MostPoints() { p.mostPoints = old.MostPoints() p.mostPointsAt = old.MostPointsAt() } if old.MostVillages() >= p.MostVillages() { p.mostVillages = old.MostVillages() p.mostVillagesAt = old.MostVillagesAt() } if old.BestRank() <= p.BestRank() { p.bestRank = old.BestRank() p.bestRankAt = old.BestRankAt() } //nolint:lll if player.Points() <= old.Points() && player.NumVillages() <= old.NumVillages() && player.OD().ScoreAtt() <= old.OD().ScoreAtt() { p.lastActivityAt = old.LastActivityAt() } } params = append(params, p) } return params, nil } func (params CreatePlayerParams) Base() BasePlayer { return params.base } func (params CreatePlayerParams) ServerKey() string { return params.serverKey } func (params CreatePlayerParams) BestRank() int { return params.bestRank } func (params CreatePlayerParams) BestRankAt() time.Time { return params.bestRankAt } func (params CreatePlayerParams) MostPoints() int { return params.mostPoints } func (params CreatePlayerParams) MostPointsAt() time.Time { return params.mostPointsAt } func (params CreatePlayerParams) MostVillages() int { return params.mostVillages } func (params CreatePlayerParams) MostVillagesAt() time.Time { return params.mostVillagesAt } func (params CreatePlayerParams) LastActivityAt() time.Time { return params.lastActivityAt } type PlayerSort uint8 const ( PlayerSortIDASC PlayerSort = iota + 1 PlayerSortIDDESC PlayerSortServerKeyASC PlayerSortServerKeyDESC PlayerSortODScoreAttASC PlayerSortODScoreAttDESC PlayerSortODScoreDefASC PlayerSortODScoreDefDESC PlayerSortODScoreSupASC PlayerSortODScoreSupDESC PlayerSortODScoreTotalASC PlayerSortODScoreTotalDESC PlayerSortPointsASC PlayerSortPointsDESC PlayerSortDeletedAtASC PlayerSortDeletedAtDESC ) func newPlayerSortFromString(s string) (PlayerSort, error) { allowed := []PlayerSort{ PlayerSortODScoreAttASC, PlayerSortODScoreAttDESC, PlayerSortODScoreDefASC, PlayerSortODScoreDefDESC, PlayerSortODScoreSupASC, PlayerSortODScoreSupDESC, PlayerSortODScoreTotalASC, PlayerSortODScoreTotalDESC, PlayerSortPointsASC, PlayerSortPointsDESC, PlayerSortDeletedAtASC, PlayerSortDeletedAtDESC, } for _, a := range allowed { if strings.EqualFold(a.String(), s) { return a, nil } } return 0, UnsupportedSortStringError{ Sort: s, } } // IsInConflict returns true if two sorts can't be used together (e.g. PlayerSortIDASC and PlayerSortIDDESC). func (s PlayerSort) IsInConflict(s2 PlayerSort) bool { ss := []PlayerSort{s, s2} slices.Sort(ss) // ASC is always an odd number, DESC is always an even number return (ss[0]%2 == 1 && ss[0] == ss[1]-1) || ss[0] == ss[1] } //nolint:gocyclo func (s PlayerSort) String() string { switch s { case PlayerSortIDASC: return "id:ASC" case PlayerSortIDDESC: return "id:DESC" case PlayerSortServerKeyASC: return "serverKey:ASC" case PlayerSortServerKeyDESC: return "serverKey:DESC" case PlayerSortODScoreAttASC: return "odScoreAtt:ASC" case PlayerSortODScoreAttDESC: return "odScoreAtt:DESC" case PlayerSortODScoreDefASC: return "odScoreDef:ASC" case PlayerSortODScoreDefDESC: return "odScoreDef:DESC" case PlayerSortODScoreSupASC: return "odScoreSup:ASC" case PlayerSortODScoreSupDESC: return "odScoreSup:DESC" case PlayerSortODScoreTotalASC: return "odScoreTotal:ASC" case PlayerSortODScoreTotalDESC: return "odScoreTotal:DESC" case PlayerSortPointsASC: return "points:ASC" case PlayerSortPointsDESC: return "points:DESC" case PlayerSortDeletedAtASC: return "deletedAt:ASC" case PlayerSortDeletedAtDESC: return "deletedAt:DESC" default: return "unknown player sort" } } type PlayerCursor struct { id int serverKey string odScoreAtt int odScoreDef int odScoreSup int odScoreTotal int points int deletedAt time.Time } const playerCursorModelName = "PlayerCursor" func NewPlayerCursor( id int, serverKey string, odScoreAtt int, odScoreDef int, odScoreSup int, odScoreTotal int, points int, deletedAt time.Time, ) (PlayerCursor, error) { if err := validateIntInRange(id, 1, math.MaxInt); err != nil { return PlayerCursor{}, ValidationError{ Model: playerCursorModelName, Field: "id", Err: err, } } if err := validateServerKey(serverKey); err != nil { return PlayerCursor{}, ValidationError{ Model: playerCursorModelName, Field: "serverKey", Err: err, } } if err := validateIntInRange(odScoreAtt, 0, math.MaxInt); err != nil { return PlayerCursor{}, ValidationError{ Model: playerCursorModelName, Field: "odScoreAtt", Err: err, } } if err := validateIntInRange(odScoreDef, 0, math.MaxInt); err != nil { return PlayerCursor{}, ValidationError{ Model: playerCursorModelName, Field: "odScoreDef", Err: err, } } if err := validateIntInRange(odScoreSup, 0, math.MaxInt); err != nil { return PlayerCursor{}, ValidationError{ Model: playerCursorModelName, Field: "odScoreSup", Err: err, } } if err := validateIntInRange(odScoreTotal, 0, math.MaxInt); err != nil { return PlayerCursor{}, ValidationError{ Model: playerCursorModelName, Field: "odScoreTotal", Err: err, } } if err := validateIntInRange(points, 0, math.MaxInt); err != nil { return PlayerCursor{}, ValidationError{ Model: playerCursorModelName, Field: "points", Err: err, } } return PlayerCursor{ id: id, serverKey: serverKey, odScoreAtt: odScoreAtt, odScoreDef: odScoreDef, odScoreSup: odScoreSup, odScoreTotal: odScoreTotal, points: points, deletedAt: deletedAt, }, nil } //nolint:gocyclo func decodePlayerCursor(encoded string) (PlayerCursor, error) { m, err := decodeCursor(encoded) if err != nil { return PlayerCursor{}, err } id, err := m.int("id") if err != nil { return PlayerCursor{}, ErrInvalidCursor } serverKey, err := m.string("serverKey") if err != nil { return PlayerCursor{}, ErrInvalidCursor } odScoreAtt, err := m.int("odScoreAtt") if err != nil { return PlayerCursor{}, ErrInvalidCursor } odScoreDef, err := m.int("odScoreDef") if err != nil { return PlayerCursor{}, ErrInvalidCursor } odScoreSup, err := m.int("odScoreSup") if err != nil { return PlayerCursor{}, ErrInvalidCursor } odScoreTotal, err := m.int("odScoreTotal") if err != nil { return PlayerCursor{}, ErrInvalidCursor } points, err := m.int("points") if err != nil { return PlayerCursor{}, ErrInvalidCursor } deletedAt, err := m.time("deletedAt") if err != nil { return PlayerCursor{}, ErrInvalidCursor } pc, err := NewPlayerCursor( id, serverKey, odScoreAtt, odScoreDef, odScoreSup, odScoreTotal, points, deletedAt, ) if err != nil { return PlayerCursor{}, ErrInvalidCursor } return pc, nil } func (pc PlayerCursor) ID() int { return pc.id } func (pc PlayerCursor) ServerKey() string { return pc.serverKey } func (pc PlayerCursor) ODScoreAtt() int { return pc.odScoreAtt } func (pc PlayerCursor) ODScoreDef() int { return pc.odScoreDef } func (pc PlayerCursor) ODScoreSup() int { return pc.odScoreSup } func (pc PlayerCursor) ODScoreTotal() int { return pc.odScoreTotal } func (pc PlayerCursor) Points() int { return pc.points } func (pc PlayerCursor) DeletedAt() time.Time { return pc.deletedAt } func (pc PlayerCursor) IsZero() bool { return pc == PlayerCursor{} } func (pc PlayerCursor) Encode() string { if pc.IsZero() { return "" } return encodeCursor([]keyValuePair{ {"id", pc.id}, {"serverKey", pc.serverKey}, {"odScoreAtt", pc.odScoreAtt}, {"odScoreDef", pc.odScoreDef}, {"odScoreSup", pc.odScoreSup}, {"odScoreTotal", pc.odScoreTotal}, {"points", pc.points}, {"deletedAt", pc.deletedAt}, }) } type ListPlayersParams struct { ids []int serverKeys []string names []string tribeIDs []int deleted NullBool sort []PlayerSort cursor PlayerCursor limit int } const ( PlayerListMaxLimit = 500 listPlayersParamsModelName = "ListPlayersParams" ) func NewListPlayersParams() ListPlayersParams { return ListPlayersParams{ sort: []PlayerSort{ PlayerSortServerKeyASC, PlayerSortIDASC, }, limit: PlayerListMaxLimit, } } func (params *ListPlayersParams) IDs() []int { return params.ids } func (params *ListPlayersParams) SetIDs(ids []int) error { for i, id := range ids { if err := validateIntInRange(id, 1, math.MaxInt); err != nil { return SliceElementValidationError{ Model: listPlayersParamsModelName, Field: "ids", Index: i, Err: err, } } } params.ids = ids return nil } func (params *ListPlayersParams) ServerKeys() []string { return params.serverKeys } func (params *ListPlayersParams) SetServerKeys(serverKeys []string) error { for i, sk := range serverKeys { if err := validateServerKey(sk); err != nil { return SliceElementValidationError{ Model: listPlayersParamsModelName, Field: "serverKeys", Index: i, Err: err, } } } params.serverKeys = serverKeys return nil } func (params *ListPlayersParams) Names() []string { return params.names } const ( playerNamesMinLength = 0 playerNamesMaxLength = 100 ) func (params *ListPlayersParams) SetNames(names []string) error { if err := validateSliceLen(names, playerNamesMinLength, playerNamesMaxLength); err != nil { return ValidationError{ Model: listPlayersParamsModelName, Field: "names", Err: err, } } for i, n := range names { if err := validateStringLen(n, playerNameMinLength, playerNameMaxLength); err != nil { return SliceElementValidationError{ Model: listPlayersParamsModelName, Field: "names", Index: i, Err: err, } } } params.names = names return nil } func (params *ListPlayersParams) TribeIDs() []int { return params.tribeIDs } func (params *ListPlayersParams) SetTribeIDs(tribeIDs []int) error { for i, id := range tribeIDs { if err := validateIntInRange(id, 1, math.MaxInt); err != nil { return SliceElementValidationError{ Model: listPlayersParamsModelName, Field: "tribeIDs", Index: i, Err: err, } } } params.tribeIDs = tribeIDs return nil } func (params *ListPlayersParams) Deleted() NullBool { return params.deleted } func (params *ListPlayersParams) SetDeleted(deleted NullBool) error { params.deleted = deleted return nil } func (params *ListPlayersParams) Sort() []PlayerSort { return params.sort } const ( playerSortMinLength = 1 playerSortMaxLength = 3 ) func (params *ListPlayersParams) SetSort(sort []PlayerSort) error { if err := validateSort(sort, playerSortMinLength, playerSortMaxLength); err != nil { return ValidationError{ Model: listPlayersParamsModelName, Field: "sort", Err: err, } } params.sort = sort return nil } func (params *ListPlayersParams) PrependSortString(sort []string) error { if err := validateSliceLen(sort, playerSortMinLength, max(playerSortMaxLength-len(params.sort), 0)); err != nil { return ValidationError{ Model: listPlayersParamsModelName, Field: "sort", Err: err, } } toPrepend := make([]PlayerSort, 0, len(sort)) for i, s := range sort { converted, err := newPlayerSortFromString(s) if err != nil { return SliceElementValidationError{ Model: listPlayersParamsModelName, Field: "sort", Index: i, Err: err, } } toPrepend = append(toPrepend, converted) } return params.SetSort(append(toPrepend, params.sort...)) } func (params *ListPlayersParams) Cursor() PlayerCursor { return params.cursor } func (params *ListPlayersParams) SetCursor(cursor PlayerCursor) error { params.cursor = cursor return nil } func (params *ListPlayersParams) SetEncodedCursor(encoded string) error { decoded, err := decodePlayerCursor(encoded) if err != nil { return ValidationError{ Model: listPlayersParamsModelName, Field: "cursor", Err: err, } } params.cursor = decoded return nil } func (params *ListPlayersParams) Limit() int { return params.limit } func (params *ListPlayersParams) SetLimit(limit int) error { if err := validateIntInRange(limit, 1, PlayerListMaxLimit); err != nil { return ValidationError{ Model: listPlayersParamsModelName, Field: "limit", Err: err, } } params.limit = limit return nil } type ListPlayersResult struct { players Players self PlayerCursor next PlayerCursor } const listPlayersResultModelName = "ListPlayersResult" func NewListPlayersResult(players Players, next Player) (ListPlayersResult, error) { var err error res := ListPlayersResult{ players: players, } if len(players) > 0 { res.self, err = players[0].ToCursor() if err != nil { return ListPlayersResult{}, ValidationError{ Model: listPlayersResultModelName, Field: "self", Err: err, } } } if !next.IsZero() { res.next, err = next.ToCursor() if err != nil { return ListPlayersResult{}, ValidationError{ Model: listPlayersResultModelName, Field: "next", Err: err, } } } return res, nil } func (res ListPlayersResult) Players() Players { return res.players } func (res ListPlayersResult) Self() PlayerCursor { return res.self } func (res ListPlayersResult) Next() PlayerCursor { return res.next } type ListPlayersWithRelationsResult struct { players PlayersWithRelations self PlayerCursor next PlayerCursor } const listPlayersWithRelationsResultModelName = "ListPlayersWithRelationsResult" func NewListPlayersWithRelationsResult( players PlayersWithRelations, next PlayerWithRelations, ) (ListPlayersWithRelationsResult, error) { var err error res := ListPlayersWithRelationsResult{ players: players, } if len(players) > 0 { res.self, err = players[0].Player().ToCursor() if err != nil { return ListPlayersWithRelationsResult{}, ValidationError{ Model: listPlayersWithRelationsResultModelName, Field: "self", Err: err, } } } if !next.IsZero() { res.next, err = next.Player().ToCursor() if err != nil { return ListPlayersWithRelationsResult{}, ValidationError{ Model: listPlayersWithRelationsResultModelName, Field: "next", Err: err, } } } return res, nil } func (res ListPlayersWithRelationsResult) Players() PlayersWithRelations { return res.players } func (res ListPlayersWithRelationsResult) Self() PlayerCursor { return res.self } func (res ListPlayersWithRelationsResult) Next() PlayerCursor { return res.next } type PlayerNotFoundError struct { ID int ServerKey string } var _ ErrorWithParams = PlayerNotFoundError{} func (e PlayerNotFoundError) Error() string { return fmt.Sprintf("player with id %d and server key %s not found", e.ID, e.ServerKey) } func (e PlayerNotFoundError) Type() ErrorType { return ErrorTypeNotFound } func (e PlayerNotFoundError) Code() string { return "player-not-found" } func (e PlayerNotFoundError) Params() map[string]any { return map[string]any{ "ID": e.ID, "ServerKey": e.ServerKey, } }