core/internal/domain/player.go

1094 lines
22 KiB
Go

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) 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) IsZero() bool {
return p.player.IsZero() && p.tribe.IsZero()
}
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
}
type PlayersWithRelations []PlayerWithRelations
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 = 200
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 = 1
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 {
od := players[0].OD()
res.self, err = NewPlayerCursor(
players[0].ID(),
players[0].ServerKey(),
od.ScoreAtt(),
od.ScoreDef(),
od.ScoreSup(),
od.ScoreTotal(),
players[0].Points(),
players[0].DeletedAt(),
)
if err != nil {
return ListPlayersResult{}, ValidationError{
Model: listPlayersResultModelName,
Field: "self",
Err: err,
}
}
}
if !next.IsZero() {
od := next.OD()
res.next, err = NewPlayerCursor(
next.ID(),
next.ServerKey(),
od.ScoreAtt(),
od.ScoreDef(),
od.ScoreSup(),
od.ScoreTotal(),
next.Points(),
next.DeletedAt(),
)
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 {
player := players[0].Player()
od := player.OD()
res.self, err = NewPlayerCursor(
player.ID(),
player.ServerKey(),
od.ScoreAtt(),
od.ScoreDef(),
od.ScoreSup(),
od.ScoreTotal(),
player.Points(),
player.DeletedAt(),
)
if err != nil {
return ListPlayersWithRelationsResult{}, ValidationError{
Model: listPlayersWithRelationsResultModelName,
Field: "self",
Err: err,
}
}
}
if !next.IsZero() {
player := next.Player()
od := player.OD()
res.next, err = NewPlayerCursor(
player.ID(),
player.ServerKey(),
od.ScoreAtt(),
od.ScoreDef(),
od.ScoreSup(),
od.ScoreTotal(),
player.Points(),
player.DeletedAt(),
)
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,
}
}