From 94c718f6d2355e31957b943f41642219f85e465d Mon Sep 17 00:00:00 2001 From: Palapeli <26661008+mkwcat@users.noreply.github.com> Date: Wed, 1 Apr 2026 00:38:31 -0400 Subject: [PATCH] Use flexible database for SAKE storage --- common/misc.go | 12 + database/sake.go | 154 ++++++++++ go.mod | 8 +- go.sum | 6 +- sake/mario_kart_wii.go | 145 ++++++++++ sake/storage.go | 623 +++++++++++++++++++++-------------------- schema.sql | 15 + 7 files changed, 659 insertions(+), 304 deletions(-) create mode 100644 database/sake.go diff --git a/common/misc.go b/common/misc.go index ab5f96c..298c802 100644 --- a/common/misc.go +++ b/common/misc.go @@ -1,4 +1,16 @@ package common +import "reflect" + func UNUSED(v ...interface{}) { } + +func ReverseMap(m interface{}) interface{} { + inputType := reflect.TypeOf(m) + inputValue := reflect.ValueOf(m) + result := reflect.MakeMap(reflect.MapOf(inputType.Elem(), inputType.Key())) + for _, key := range inputValue.MapKeys() { + result.SetMapIndex(inputValue.MapIndex(key), key) + } + return result.Interface() +} diff --git a/database/sake.go b/database/sake.go new file mode 100644 index 0000000..c545369 --- /dev/null +++ b/database/sake.go @@ -0,0 +1,154 @@ +package database + +import ( + "context" + "encoding/json" + "errors" + + "github.com/jackc/pgconn" + "github.com/jackc/pgerrcode" + "github.com/jackc/pgx/v4/pgxpool" +) + +const ( + SakeFieldTypeByte = 0 + SakeFieldTypeShort = 1 + SakeFieldTypeInt = 2 + SakeFieldTypeFloat = 3 + SakeFieldTypeAsciiString = 4 + SakeFieldTypeUnicodeString = 5 + SakeFieldTypeBoolean = 6 + SakeFieldTypeDateAndTime = 7 + SakeFieldTypeBinaryData = 8 + SakeFieldTypeInt64 = 9 +) + +type SakeFieldType int + +type SakeField struct { + Type SakeFieldType `json:"type"` + Value string `json:"value"` +} + +type SakeRecord struct { + GameId int + OwnerId int32 + TableId string + RecordId int32 + Fields map[string]SakeField +} + +const ( + getSakeRecordsQuery = ` + SELECT owner_id, record_id, fields + FROM sake_records + WHERE game_id = $1 + AND table_id = $2 + AND (cardinality($4::integer[]) = 0 OR record_id = ANY($4::integer[])) + AND (cardinality($3::integer[]) = 0 OR owner_id = ANY($3::integer[]))` + updateSakeRecordQuery = ` + UPDATE sake_records + SET + fields = CASE WHEN owner_id = $4 THEN fields || $5 ELSE fields END, + update_time = CASE WHEN owner_id = $4 THEN CURRENT_TIMESTAMP ELSE update_time END + WHERE game_id = $1 + AND table_id = $2 + AND record_id = $3 + RETURNING owner_id` + insertSakeRecordQuery = ` + INSERT INTO sake_records (game_id, table_id, owner_id, fields) + VALUES ($1, $2, $3, $4)` +) + +var ( + ErrSakeNotOwned = errors.New("record is not owned by the specified owner ID") +) + +func parseSakeFieldsFromJson(fieldsJson []byte) (map[string]SakeField, error) { + var fields map[string]SakeField + err := json.Unmarshal(fieldsJson, &fields) + if err != nil { + return nil, err + } + + return fields, nil +} + +func GetSakeRecords(pool *pgxpool.Pool, ctx context.Context, gameId int, ownerIds []int32, tableId string, recordIds []int32, fields []string, filter string) ([]SakeRecord, error) { + if fields == nil { + fields = []string{} + } + if ownerIds == nil { + ownerIds = []int32{} + } + if recordIds == nil { + recordIds = []int32{} + } + + rows, err := pool.Query(ctx, getSakeRecordsQuery, gameId, tableId, ownerIds, recordIds) + if err != nil { + return nil, err + } + defer rows.Close() + + var records []SakeRecord + for rows.Next() { + record := SakeRecord{ + GameId: gameId, + TableId: tableId, + } + var fieldsJson []byte + if err := rows.Scan(&record.OwnerId, &record.RecordId, &fieldsJson); err != nil { + return nil, err + } + fields, err := parseSakeFieldsFromJson(fieldsJson) + if err != nil { + return nil, err + } + record.Fields = fields + + records = append(records, record) + } + + return records, nil +} + +func UpdateSakeRecord(pool *pgxpool.Pool, ctx context.Context, record SakeRecord, ownerId int32) error { + fieldsJson, err := json.Marshal(record.Fields) + if err != nil { + return err + } + var existingOwnerId int32 + err = pool.QueryRow(ctx, updateSakeRecordQuery, record.GameId, record.TableId, record.RecordId, ownerId, fieldsJson).Scan(&existingOwnerId) + if err != nil { + return err + } + if ownerId != 0 && existingOwnerId != ownerId { + return ErrSakeNotOwned + } + + return nil +} + +func InsertSakeRecord(pool *pgxpool.Pool, ctx context.Context, record SakeRecord) error { + fieldsJson, err := json.Marshal(record.Fields) + if err != nil { + return err + } + + for i := 0; i < 10; i++ { + _, err = pool.Exec(ctx, insertSakeRecordQuery, record.GameId, record.TableId, record.OwnerId, fieldsJson) + if err == nil { + break + } + var pgErr *pgconn.PgError + if !errors.As(err, &pgErr) { + break + } + if pgErr.Code != pgerrcode.UniqueViolation { + break + } + // Retry if unique violation occurred, as the record ID is generated randomly + } + return nil +} diff --git a/go.mod b/go.mod index f4f7d24..ff39533 100644 --- a/go.mod +++ b/go.mod @@ -8,17 +8,21 @@ require ( github.com/jackc/pgx/v4 v4.18.3 github.com/logrusorgru/aurora/v3 v3.0.0 golang.org/x/net v0.47.0 - github.com/linkdata/deadlock v0.5.5 // indirect + gvisor.dev/gvisor v0.0.0-20250512220230-2268d0cbb0f5 +) + +require ( github.com/jackc/chunkreader/v2 v2.0.1 // indirect github.com/jackc/pgconn v1.14.3 // indirect + github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6 // indirect github.com/jackc/pgio v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgproto3/v2 v2.3.3 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgtype v1.14.4 // indirect github.com/jackc/puddle v1.3.0 // indirect + github.com/linkdata/deadlock v0.5.5 // indirect github.com/petermattis/goid v0.0.0-20250813065127-a731cc31b4fe // indirect golang.org/x/crypto v0.45.0 // indirect golang.org/x/text v0.31.0 // indirect - gvisor.dev/gvisor v0.0.0-20250512220230-2268d0cbb0f5 ) diff --git a/go.sum b/go.sum index ec84af6..c28e11e 100644 --- a/go.sum +++ b/go.sum @@ -26,6 +26,8 @@ github.com/jackc/pgconn v1.9.0/go.mod h1:YctiPyvzfU11JFxoXokUOOKQXQmDMoJL9vJzHH8 github.com/jackc/pgconn v1.9.1-0.20210724152538-d89c8390a530/go.mod h1:4z2w8XhRbP1hYxkpTuBjTS3ne3J48K83+u0zoyvg2pI= github.com/jackc/pgconn v1.14.3 h1:bVoTr12EGANZz66nZPkMInAV/KHD2TxH9npjXXgiB3w= github.com/jackc/pgconn v1.14.3/go.mod h1:RZbme4uasqzybK2RK5c65VsHxoyaml09lx3tXOcO/VM= +github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6 h1:D/V0gu4zQ3cL2WKeVNVM4r2gLxGGf6McLwgXzRTo2RQ= +github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds= github.com/jackc/pgio v1.0.0 h1:g12B9UwVnzGhueNavwioyEEpAmqMe1E/BN9ES+8ovkE= github.com/jackc/pgio v1.0.0/go.mod h1:oP+2QK2wFfUWgr+gxjoBH9KGBb31Eio69xUb0w5bYf8= github.com/jackc/pgmock v0.0.0-20190831213851-13a1b77aafa2/go.mod h1:fGZlG77KXmcq05nJLRkk0+p82V8B8Dw8KN2/V9c/OAE= @@ -78,6 +80,8 @@ github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.10.2 h1:AqzbZs4ZoCBp+GtejcpCpcxM3zlSMx29dXbUSeVtJb8= github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/linkdata/deadlock v0.3.5 h1:tNCOEEDG6tBqrNDOX35j/7hL5FcFViG6awUGROb2NsU= +github.com/linkdata/deadlock v0.3.5/go.mod h1:bugP6EGbdGYObIlx7pUZtWqlvo8k9H6vCBBsiChJQ5U= github.com/linkdata/deadlock v0.5.5 h1:d6O+rzEqasSfamGDA8u7bjtaq7hOX8Ha4Zn36Wxrkvo= github.com/linkdata/deadlock v0.5.5/go.mod h1:tXb28stzAD3trzEEK0UJWC+rZKuobCoPktPYzebb1u0= github.com/logrusorgru/aurora/v3 v3.0.0 h1:R6zcoZZbvVcGMvDCKo45A9U/lzYyzl5NfYIvznmDfE4= @@ -101,8 +105,6 @@ github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFR github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ= github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU= github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc= -github.com/linkdata/deadlock v0.3.5 h1:tNCOEEDG6tBqrNDOX35j/7hL5FcFViG6awUGROb2NsU= -github.com/linkdata/deadlock v0.3.5/go.mod h1:bugP6EGbdGYObIlx7pUZtWqlvo8k9H6vCBBsiChJQ5U= github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4= github.com/shopspring/decimal v1.2.0 h1:abSATXmQEYyShuxI4/vyW3tV1MrKAJzCZ/0zLUXYbsQ= diff --git a/sake/mario_kart_wii.go b/sake/mario_kart_wii.go index 101fbd7..1f5b8c3 100644 --- a/sake/mario_kart_wii.go +++ b/sake/mario_kart_wii.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "net/http" + "regexp" "strconv" "strings" "wwfc/common" @@ -29,6 +30,150 @@ const ( rkgdFileName = "ghost.bin" ) +var ( + ghostDataFilterRegex = regexp.MustCompile(`^course = ([1-9]\d?|0) and gameid = 1687 and time < ([1-9][0-9]{0,5})$`) +) + +func getMarioKartWiiGhostDataRecord(moduleName string, request StorageRequestData) (database.SakeRecord, bool) { + if request.Sort != "time desc" { + logging.Error(moduleName, "mariokartwii/GhostData: Invalid sort string:", aurora.Cyan(request.Sort)) + return database.SakeRecord{}, false + } + + if request.Offset != 0 { + logging.Error(moduleName, "mariokartwii/GhostData: Invalid offset value:", aurora.Cyan(request.Offset)) + return database.SakeRecord{}, false + } + + if request.Max != 1 { + logging.Error(moduleName, "mariokartwii/GhostData: Invalid number of records to return:", aurora.Cyan(request.Max)) + return database.SakeRecord{}, false + } + + if request.Surrounding != 0 { + logging.Error(moduleName, "mariokartwii/GhostData: Invalid number of surrounding records to return:", aurora.Cyan(request.Surrounding)) + return database.SakeRecord{}, false + } + + if len(request.OwnerIDs.OwnerID) != 0 { + logging.Error(moduleName, "mariokartwii/GhostData: Invalid owner id array:", aurora.Cyan(request.OwnerIDs)) + return database.SakeRecord{}, false + } + + if request.CacheFlag != 0 { + logging.Error(moduleName, "mariokartwii/GhostData: Invalid cache value:", aurora.Cyan(request.CacheFlag)) + return database.SakeRecord{}, false + } + + match := ghostDataFilterRegex.FindStringSubmatch(request.Filter) + if match == nil { + logging.Error(moduleName, "mariokartwii/GhostData: Invalid filter string:", aurora.Cyan(request.Filter)) + return database.SakeRecord{}, false + } + + courseIdInt, _ := strconv.Atoi(match[1]) + courseId := common.MarioKartWiiCourseId(courseIdInt) + if !courseId.IsValid() { + logging.Error(moduleName, "mariokartwii/GhostData: Invalid course ID:", aurora.Cyan(match[1])) + return database.SakeRecord{}, false + } + + time, _ := strconv.Atoi(match[2]) + if time >= 360000 /* 6 minutes */ { + logging.Error(moduleName, "mariokartwii/GhostData: Invalid time:", aurora.Cyan(match[2])) + return database.SakeRecord{}, false + } + + fileId, err := database.GetMarioKartWiiGhostData(pool, ctx, courseId, time) + if err != nil { + logging.Error(moduleName, "mariokartwii/GhostData: Failed to get the ghost data from the database:", err) + return database.SakeRecord{}, false + } + + return database.SakeRecord{ + GameId: 1687, + TableId: "GhostData", + RecordId: 0, + OwnerId: 0, + Fields: map[string]database.SakeField{ + "fileid": { + Type: database.SakeFieldTypeInt, + Value: strconv.FormatInt(int64(int32(fileId)), 10), + }, + }, + }, true +} + +func getMarioKartWiiStoredGhostDataRecord(moduleName string, request StorageRequestData) (database.SakeRecord, bool) { + if request.Sort != "time" { + logging.Error(moduleName, "mariokartwii/StoredGhostData: Invalid sort string:", aurora.Cyan(request.Sort)) + return database.SakeRecord{}, false + } + + if request.Offset != 0 { + logging.Error(moduleName, "mariokartwii/StoredGhostData: Invalid offset value:", aurora.Cyan(request.Offset)) + return database.SakeRecord{}, false + } + + if request.Max != 1 { + logging.Error(moduleName, "mariokartwii/StoredGhostData: Invalid number of records to return:", aurora.Cyan(request.Max)) + return database.SakeRecord{}, false + } + + if request.Surrounding != 0 { + logging.Error(moduleName, "mariokartwii/StoredGhostData: Invalid number of surrounding records to return:", aurora.Cyan(request.Surrounding)) + return database.SakeRecord{}, false + } + + if len(request.OwnerIDs.OwnerID) != 0 { + logging.Error(moduleName, "mariokartwii/StoredGhostData: Invalid owner id array:", aurora.Cyan(request.OwnerIDs)) + return database.SakeRecord{}, false + } + + if request.CacheFlag != 0 { + logging.Error(moduleName, "mariokartwii/StoredGhostData: Invalid cache value:", aurora.Cyan(request.CacheFlag)) + return database.SakeRecord{}, false + } + + match := regexp.MustCompile(`^course = ([1-9]\d?|0) and gameid = 1687(?: and region = ([1-7]))?$`).FindStringSubmatch(request.Filter) + if match == nil { + logging.Error(moduleName, "mariokartwii/StoredGhostData: Invalid filter string:", aurora.Cyan(request.Filter)) + return database.SakeRecord{}, false + } + + courseIdInt, _ := strconv.Atoi(match[1]) + courseId := common.MarioKartWiiCourseId(courseIdInt) + if !courseId.IsValid() { + logging.Error(moduleName, "mariokartwii/StoredGhostData: Invalid course ID:", aurora.Cyan(match[1])) + return database.SakeRecord{}, false + } + + var regionId common.MarioKartWiiLeaderboardRegionId + if regionIdExists := match[2] != ""; regionIdExists { + regionIdInt, _ := strconv.Atoi(match[2]) + regionId = common.MarioKartWiiLeaderboardRegionId(regionIdInt) + } else { + regionId = common.Worldwide + } + + pid, fileId, err := database.GetMarioKartWiiStoredGhostData(pool, ctx, regionId, courseId) + if err != nil { + logging.Error(moduleName, "mariokartwii/StoredGhostData: Failed to get the stored ghost data from the database:", err) + return database.SakeRecord{}, false + } + + return database.SakeRecord{ + GameId: 1687, + TableId: "StoredGhostData", + RecordId: 0, + OwnerId: int32(pid), + Fields: map[string]database.SakeField{ + "profile": {Type: database.SakeFieldTypeInt, Value: strconv.FormatInt(int64(int32(pid)), 10)}, + "fileid": {Type: database.SakeFieldTypeInt, Value: strconv.FormatInt(int64(int32(fileId)), 10)}, + }, + }, true +} + func handleMarioKartWiiFileDownloadRequest(moduleName string, responseWriter http.ResponseWriter, request *http.Request) { if strings.HasSuffix(request.URL.Path, "ghostdownload.aspx") { handleMarioKartWiiGhostDownloadRequest(moduleName, responseWriter, request) diff --git a/sake/storage.go b/sake/storage.go index 82159f5..9ee4099 100644 --- a/sake/storage.go +++ b/sake/storage.go @@ -1,20 +1,43 @@ package sake import ( - "encoding/base64" "encoding/xml" "io" "net/http" "regexp" "sort" "strconv" + "time" "wwfc/common" "wwfc/database" "wwfc/logging" + "github.com/jackc/pgx/v4" "github.com/logrusorgru/aurora/v3" ) +const ( + ResultSuccess = "Success" + ResultSecretKeyInvalid = "SecretKeyInvalid" + ResultServiceDisabled = "ServiceDisabled" + ResultDatabaseUnavailable = "DatabaseUnavailable" + ResultLoginTicketInvalid = "LoginTicketInvalid" + ResultLoginTicketExpired = "LoginTicketExpired" + ResultTableNotFound = "TableNotFound" + ResultRecordNotFound = "RecordNotFound" + ResultFieldNotFound = "FieldNotFound" + ResultFieldTypeInvalid = "FieldTypeInvalid" + ResultNoPermission = "NoPermission" + ResultRecordLimitReached = "RecordLimitReached" + ResultAlreadyRated = "AlreadyRated" + ResultNotRateable = "NotRateable" + ResultNotOwned = "NotOwned" + ResultFilterInvalid = "FilterInvalid" + ResultSortInvalid = "SortInvalid" + ResultTargetFilterInvalid = "TargetFilterInvalid" + ResultUnknownError = "UnknownError" +) + const ( SOAPEnvNamespace = "http://schemas.xmlsoap.org/soap/envelope/" SakeNamespace = "http://gamespy.net/sake" @@ -36,13 +59,13 @@ type StorageRequestData struct { SecretKey string `xml:"secretKey"` LoginTicket string `xml:"loginTicket"` TableID string `xml:"tableid"` - RecordID string `xml:"recordid"` + RecordID int32 `xml:"recordid"` Filter string `xml:"filter"` Sort string `xml:"sort"` Offset int `xml:"offset"` Max int `xml:"max"` Surrounding int `xml:"surrounding"` - OwnerIDs string `xml:"ownerids"` + OwnerIDs StorageOwnerIDs `xml:"ownerids"` CacheFlag int `xml:"cacheFlag"` Fields StorageFields `xml:"fields"` Values StorageUpdateRecordValues `xml:"values"` @@ -72,46 +95,68 @@ type StorageValue struct { Value string `xml:"value"` } +type StorageOwnerIDs struct { + OwnerID []int32 `xml:"int"` +} + type StorageResponseEnvelope struct { XMLName xml.Name - Body StorageResponseBody + Body StorageResponseBody `xml:"http://schemas.xmlsoap.org/soap/envelope/ Body"` } type StorageResponseBody struct { - XMLName xml.Name GetMyRecordsResponse *StorageGetMyRecordsResponse `xml:"http://gamespy.net/sake GetMyRecordsResponse"` UpdateRecordResponse *StorageUpdateRecordResponse `xml:"http://gamespy.net/sake UpdateRecordResponse"` SearchForRecordsResponse *StorageSearchForRecordsResponse `xml:"http://gamespy.net/sake SearchForRecordsResponse"` } type StorageGetMyRecordsResponse struct { - XMLName xml.Name GetMyRecordsResult string - Values StorageResponseValues `xml:"values"` // ??? + Values StorageResponseValues `xml:"values"` } type StorageResponseValues struct { - XMLName xml.Name - ArrayOfRecordValue StorageArrayOfRecordValue + ArrayOfRecordValue []StorageArrayOfRecordValue `xml:"ArrayOfRecordValue"` } type StorageArrayOfRecordValue struct { - XMLName xml.Name RecordValues []StorageRecordValue `xml:"RecordValue"` } type StorageUpdateRecordResponse struct { - XMLName xml.Name UpdateRecordResult string - // TODO } type StorageSearchForRecordsResponse struct { XMLName xml.Name SearchForRecordsResult string - Values StorageResponseValues `xml:"values"` // ??? + Values StorageResponseValues `xml:"values"` } +var ( + sakeTypeToTag = map[database.SakeFieldType]string{ + database.SakeFieldTypeByte: "byteValue", + database.SakeFieldTypeShort: "shortValue", + database.SakeFieldTypeInt: "intValue", + database.SakeFieldTypeFloat: "floatValue", + database.SakeFieldTypeAsciiString: "asciiStringValue", + database.SakeFieldTypeUnicodeString: "unicodeStringValue", + database.SakeFieldTypeBoolean: "booleanValue", + database.SakeFieldTypeDateAndTime: "dateAndTimeValue", + database.SakeFieldTypeBinaryData: "binaryDataValue", + database.SakeFieldTypeInt64: "int64Value", + } + + tagToSakeType = common.ReverseMap(sakeTypeToTag).(map[string]database.SakeFieldType) + + storageRequestHandlers = map[string]func(moduleName string, profileId uint32, gameInfo common.GameInfo, request StorageRequestData) StorageResponseBody{ + SakeNamespace + "/CreateRecord": createRecord, + SakeNamespace + "/UpdateRecord": updateRecord, + SakeNamespace + "/GetMyRecords": getMyRecords, + SakeNamespace + "/SearchForRecords": searchForRecords, + } +) + func handleStorageRequest(moduleName string, w http.ResponseWriter, r *http.Request) { headerAction := r.Header.Get("SOAPAction") if headerAction == "" { @@ -124,6 +169,8 @@ func handleStorageRequest(moduleName string, w http.ResponseWriter, r *http.Requ panic(err) } + logging.Info("SAKE", "Received storage request with SOAPAction:", aurora.Yellow(headerAction), "and body:", aurora.Cyan(string(body))) + // Parse the SOAP request XML soap := StorageRequestEnvelope{} err = xml.Unmarshal(body, &soap) @@ -134,29 +181,23 @@ func handleStorageRequest(moduleName string, w http.ResponseWriter, r *http.Requ response := StorageResponseEnvelope{ XMLName: xml.Name{Space: SOAPEnvNamespace, Local: "Envelope"}, - Body: StorageResponseBody{ - XMLName: xml.Name{Space: SOAPEnvNamespace, Local: "Body"}, - }, } xmlName := soap.Body.Data.XMLName.Space + "/" + soap.Body.Data.XMLName.Local if headerAction == xmlName || headerAction == `"`+xmlName+`"` { logging.Info(moduleName, "SOAPAction:", aurora.Yellow(soap.Body.Data.XMLName.Local)) - if profileId, gameInfo, ok := getRequestIdentity(moduleName, soap.Body.Data); ok { - switch xmlName { - case SakeNamespace + "/GetMyRecords": - response.Body.GetMyRecordsResponse = getMyRecords(moduleName, profileId, gameInfo, soap.Body.Data) + handler, ok := storageRequestHandlers[xmlName] + if !ok { + panic("unknown SOAPAction: " + aurora.Cyan(xmlName).String()) + } - case SakeNamespace + "/UpdateRecord": - response.Body.UpdateRecordResponse = updateRecord(moduleName, profileId, gameInfo, soap.Body.Data) - - case SakeNamespace + "/SearchForRecords": - response.Body.SearchForRecordsResponse = searchForRecords(moduleName, gameInfo, soap.Body.Data) - - default: - logging.Error(moduleName, "Unknown SOAPAction:", aurora.Cyan(xmlName)) - } + profileId, gameInfo, errorString := getRequestIdentity(moduleName, soap.Body.Data) + if errorString != ResultSuccess { + logging.Error(moduleName, "Failed to get request identity:", aurora.Cyan(errorString)) + response.Body.setResultTag(xmlName, errorString) + } else { + response.Body = handler(moduleName, profileId, gameInfo, soap.Body.Data) } } else { logging.Error(moduleName, "Invalid SOAPAction or XML request:", aurora.Cyan(headerAction)) @@ -174,316 +215,243 @@ func handleStorageRequest(moduleName string, w http.ResponseWriter, r *http.Requ w.Write(payload) } -func getRequestIdentity(moduleName string, request StorageRequestData) (uint32, common.GameInfo, bool) { +func getRequestIdentity(moduleName string, request StorageRequestData) (uint32, common.GameInfo, string) { gameInfo := common.GetGameInfoByID(request.GameID) if gameInfo == nil { logging.Error(moduleName, "Invalid game ID:", aurora.Cyan(request.GameID)) - return 0, common.GameInfo{}, false + return 0, common.GameInfo{}, ResultDatabaseUnavailable } if gameInfo.SecretKey != request.SecretKey { logging.Error(moduleName, "Mismatch", aurora.BrightCyan(gameInfo.Name), "secret key:", aurora.Cyan(request.SecretKey), "!=", aurora.BrightCyan(gameInfo.SecretKey)) - return 0, common.GameInfo{}, false + return 0, common.GameInfo{}, ResultSecretKeyInvalid } - profileId, _, err := common.UnmarshalGPCMLoginTicket(request.LoginTicket) + profileId, issueTime, err := common.UnmarshalGPCMLoginTicket(request.LoginTicket) if err != nil { logging.Error(moduleName, err) - return 0, common.GameInfo{}, false + return 0, common.GameInfo{}, ResultLoginTicketInvalid } - logging.Info(moduleName, "Profile ID:", aurora.BrightCyan(profileId)) - logging.Info(moduleName, "Game:", aurora.Cyan(request.GameID), "-", aurora.BrightCyan(gameInfo.Name)) - logging.Info(moduleName, "Table ID:", aurora.Cyan(request.TableID)) + if issueTime.Add(48 * time.Hour).Before(time.Now()) { + return 0, common.GameInfo{}, ResultLoginTicketExpired + } - return profileId, *gameInfo, true + return profileId, *gameInfo, ResultSuccess } -func binaryDataValue(value []byte) StorageValue { - return StorageValue{ - XMLName: xml.Name{Local: "binaryDataValue"}, - Value: base64.StdEncoding.EncodeToString(value), +func getInputFields(moduleName string, request StorageRequestData) (map[string]database.SakeField, string) { + record := database.SakeRecord{ + Fields: make(map[string]database.SakeField), } -} - -func binaryDataValueBase64(value string) StorageValue { - return StorageValue{ - XMLName: xml.Name{Local: "binaryDataValue"}, - Value: value, - } -} - -func intValue(value int32) StorageValue { - return StorageValue{ - XMLName: xml.Name{Local: "intValue"}, - Value: strconv.FormatInt(int64(value), 10), - } -} - -// I don't even know if this is a thing -func uintValue(value uint32) StorageValue { - return StorageValue{ - XMLName: xml.Name{Local: "uintValue"}, - Value: strconv.FormatUint(uint64(value), 10), - } -} - -func getMyRecords(moduleName string, profileId uint32, gameInfo common.GameInfo, request StorageRequestData) *StorageGetMyRecordsResponse { - errorResponse := StorageGetMyRecordsResponse{ - GetMyRecordsResult: "Error", - } - - var values map[string]StorageValue - - switch gameInfo.Name + "/" + request.TableID { - default: - logging.Error(moduleName, "Unknown table") - for _, field := range request.Fields.Fields { - logging.Info(moduleName, "Field:", aurora.Cyan(field)) + for _, field := range request.Values.RecordFields { + fieldType, ok := tagToSakeType[field.Value.Value.XMLName.Local] + if !ok { + logging.Error(moduleName, "Invalid field type tag:", aurora.Cyan(field.Value.Value.XMLName.Local)) + return nil, ResultFieldTypeInvalid } - return &errorResponse - - case "mariokartwii/FriendInfo": - // Mario Kart Wii friend info - values = map[string]StorageValue{ - "ownerid": uintValue(profileId), - "recordid": intValue(int32(profileId)), - "info": binaryDataValueBase64(database.GetMKWFriendInfo(pool, ctx, profileId)), + if field.Name == "ownerid" || field.Name == "recordid" { + logging.Error(moduleName, "Attempt to set reserved field:", aurora.Cyan(field.Name)) + return nil, ResultNoPermission } + record.Fields[field.Name] = database.SakeField{Type: fieldType, Value: field.Value.Value.Value} + } + return record.Fields, ResultSuccess +} + +func createRecord(moduleName string, profileId uint32, gameInfo common.GameInfo, request StorageRequestData) StorageResponseBody { + if request.TableID == "" { + logging.Error(moduleName, "No table ID provided") + return StorageResponseBody{UpdateRecordResponse: &StorageUpdateRecordResponse{ + UpdateRecordResult: ResultTableNotFound, + }} + } + + if gameInfo.Name == "mariokartwii" && (request.TableID == "GhostData" || request.TableID == "StoredGhostData") { + // Reserved for special handler + return StorageResponseBody{UpdateRecordResponse: &StorageUpdateRecordResponse{ + UpdateRecordResult: ResultTableNotFound, + }} + } + + var record database.SakeRecord + var result string + record.Fields, result = getInputFields(moduleName, request) + if result != ResultSuccess { + return StorageResponseBody{UpdateRecordResponse: &StorageUpdateRecordResponse{ + UpdateRecordResult: result, + }} + } + + record.GameId = gameInfo.GameID + record.TableId = request.TableID + record.RecordId = 0 + record.OwnerId = int32(profileId) + + // TODO: Limit number of records or fields a user can have + err := database.InsertSakeRecord(pool, ctx, record) + if err != nil { + return StorageResponseBody{UpdateRecordResponse: &StorageUpdateRecordResponse{ + UpdateRecordResult: ResultDatabaseUnavailable, + }} + } + + return StorageResponseBody{UpdateRecordResponse: &StorageUpdateRecordResponse{ + UpdateRecordResult: ResultSuccess, + }} + +} + +func getMyRecords(moduleName string, profileId uint32, gameInfo common.GameInfo, request StorageRequestData) StorageResponseBody { + if len(request.Fields.Fields) == 0 { + // GameSpy client doesn't consider zero fields valid + return StorageResponseBody{GetMyRecordsResponse: &StorageGetMyRecordsResponse{ + GetMyRecordsResult: "BadNumFields", + }} + } + if request.TableID == "" { + logging.Error(moduleName, "No table ID provided") + return StorageResponseBody{GetMyRecordsResponse: &StorageGetMyRecordsResponse{ + GetMyRecordsResult: ResultTableNotFound, + }} + } + + records, err := database.GetSakeRecords(pool, ctx, gameInfo.GameID, []int32{int32(profileId)}, request.TableID, nil, request.Fields.Fields, request.Filter) + if err != nil { + logging.Error(moduleName, "Failed to get sake records from the database:", err) + if err == pgx.ErrNoRows { + return StorageResponseBody{GetMyRecordsResponse: &StorageGetMyRecordsResponse{ + GetMyRecordsResult: ResultRecordNotFound, + }} + } + return StorageResponseBody{GetMyRecordsResponse: &StorageGetMyRecordsResponse{ + GetMyRecordsResult: ResultDatabaseUnavailable, + }} } response := StorageGetMyRecordsResponse{ - GetMyRecordsResult: "Success", + GetMyRecordsResult: ResultSuccess, + Values: fillResponseValues(records, request), } + logging.Info(moduleName, "Returning", aurora.Cyan(len(records)), "records from table", aurora.Cyan(request.TableID), "for profile", aurora.Cyan(profileId)) - fieldCount := 0 - valueArray := &response.Values.ArrayOfRecordValue - for _, field := range request.Fields.Fields { - if value, ok := values[field]; ok { - fieldCount++ - valueArray.RecordValues = append(valueArray.RecordValues, StorageRecordValue{Value: &value}) - } else { - valueArray.RecordValues = append(valueArray.RecordValues, StorageRecordValue{Value: nil}) - } - } - - logging.Info(moduleName, "Wrote", aurora.Cyan(fieldCount), "field(s)") - return &response + return StorageResponseBody{GetMyRecordsResponse: &response} } -func updateRecord(moduleName string, profileId uint32, gameInfo common.GameInfo, request StorageRequestData) *StorageUpdateRecordResponse { - errorResponse := StorageUpdateRecordResponse{ - UpdateRecordResult: "Error", +func updateRecord(moduleName string, profileId uint32, gameInfo common.GameInfo, request StorageRequestData) StorageResponseBody { + if request.TableID == "" { + logging.Error(moduleName, "No table ID provided") + return StorageResponseBody{UpdateRecordResponse: &StorageUpdateRecordResponse{ + UpdateRecordResult: ResultTableNotFound, + }} } - switch gameInfo.Name + "/" + request.TableID { - default: - logging.Error(moduleName, "Unknown table") - for _, field := range request.Values.RecordFields { - logging.Info(moduleName, "Field:", aurora.Cyan(field.Name), "Type:", aurora.Cyan(field.Value.XMLName.Local), "Value:", aurora.Cyan(field.Value.Value.Value)) + if gameInfo.Name == "mariokartwii" && (request.TableID == "GhostData" || request.TableID == "StoredGhostData") { + // Reserved for special handler + return StorageResponseBody{UpdateRecordResponse: &StorageUpdateRecordResponse{ + UpdateRecordResult: ResultTableNotFound, + }} + } + + var record database.SakeRecord + var result string + record.Fields, result = getInputFields(moduleName, request) + if result != ResultSuccess { + return StorageResponseBody{UpdateRecordResponse: &StorageUpdateRecordResponse{ + UpdateRecordResult: result, + }} + } + + record.GameId = gameInfo.GameID + record.TableId = request.TableID + record.RecordId = request.RecordID + record.OwnerId = int32(profileId) + + err := database.UpdateSakeRecord(pool, ctx, record, int32(profileId)) + if err != nil { + logging.Error(moduleName, "Failed to update sake record in the database:", err) + if err == database.ErrSakeNotOwned { + return StorageResponseBody{UpdateRecordResponse: &StorageUpdateRecordResponse{ + UpdateRecordResult: ResultNotOwned, + }} } - return &errorResponse - - case "mariokartwii/FriendInfo": - // Mario Kart Wii friend info - if len(request.Values.RecordFields) != 1 || request.Values.RecordFields[0].Name != "info" || request.Values.RecordFields[0].Value.Value.XMLName.Local != "binaryDataValue" { - logging.Error(moduleName, "Invalid record fields") - return &errorResponse + if err == pgx.ErrNoRows { + return StorageResponseBody{UpdateRecordResponse: &StorageUpdateRecordResponse{ + UpdateRecordResult: ResultRecordNotFound, + }} } - - // TODO: Validate record data - database.UpdateMKWFriendInfo(pool, ctx, profileId, request.Values.RecordFields[0].Value.Value.Value) - logging.Notice(moduleName, "Updated Mario Kart Wii friend info") + return StorageResponseBody{UpdateRecordResponse: &StorageUpdateRecordResponse{ + UpdateRecordResult: ResultDatabaseUnavailable, + }} } - return &StorageUpdateRecordResponse{ - UpdateRecordResult: "Success", - } + logging.Info(moduleName, "Updated record", aurora.Cyan(record.RecordId), "in table", aurora.Cyan(record.TableId), "for profile", aurora.Cyan(profileId)) + + return StorageResponseBody{UpdateRecordResponse: &StorageUpdateRecordResponse{ + UpdateRecordResult: ResultSuccess, + }} } -func searchForRecords(moduleName string, gameInfo common.GameInfo, request StorageRequestData) *StorageSearchForRecordsResponse { - errorResponse := StorageSearchForRecordsResponse{ - SearchForRecordsResult: "Error", - } +var searchRecordHandlers = map[string]func(string, StorageRequestData) (database.SakeRecord, bool){ + "mariokartwii/GhostData": getMarioKartWiiGhostDataRecord, + "mariokartwii/StoredGhostData": getMarioKartWiiStoredGhostDataRecord, +} - var values []map[string]StorageValue +var ( + standardFilterRegex = regexp.MustCompile(`^ownerid\s*=\s*-?(\d{1,10})$`) +) - switch gameInfo.Name + "/" + request.TableID { - default: - logging.Error(moduleName, "Unknown table") - for _, field := range request.Fields.Fields { - logging.Info(moduleName, "Field:", aurora.Cyan(field)) +// TODO: Test this +func searchForRecords(moduleName string, profileId uint32, gameInfo common.GameInfo, request StorageRequestData) StorageResponseBody { + var records []database.SakeRecord + + if handler, ok := searchRecordHandlers[gameInfo.Name+"/"+request.TableID]; ok { + record, ok := handler(moduleName, request) + if !ok { + return StorageResponseBody{SearchForRecordsResponse: &StorageSearchForRecordsResponse{ + SearchForRecordsResult: ResultUnknownError, + }} } - return &errorResponse + records = append(records, record) + } else { + filter := request.Filter + ownerIds := request.OwnerIDs.OwnerID - case "mariokartwii/FriendInfo": - // Mario Kart Wii friend info - match := regexp.MustCompile(`^ownerid = (\d{1,10})$`).FindStringSubmatch(request.Filter) - if len(match) != 2 { - logging.Error(moduleName, "Invalid filter") - return &errorResponse + // Parse standard filter + if matches := standardFilterRegex.FindStringSubmatch(request.Filter); matches != nil { + ownerIdParsed, err := strconv.ParseInt(matches[1], 10, 32) + if err != nil { + logging.Error(moduleName, "Invalid owner ID in filter:", aurora.Cyan(request.Filter)) + return StorageResponseBody{SearchForRecordsResponse: &StorageSearchForRecordsResponse{ + SearchForRecordsResult: ResultFilterInvalid, + }} + } + ownerIds = append(ownerIds, int32(ownerIdParsed)) + filter = "" } - ownerId, err := strconv.ParseInt(match[1], 10, 32) + var err error + records, err = database.GetSakeRecords(pool, ctx, gameInfo.GameID, ownerIds, request.TableID, nil, request.Fields.Fields, filter) if err != nil { - logging.Error(moduleName, "Invalid owner ID") - return &errorResponse - } - - values = []map[string]StorageValue{ - { - "ownerid": uintValue(uint32(ownerId)), - "recordid": intValue(int32(ownerId)), - "info": binaryDataValueBase64(database.GetMKWFriendInfo(pool, ctx, uint32(ownerId))), - }, - } - - case "mariokartwii/GhostData": - if request.TableID != "GhostData" { - logging.Error(moduleName, "Invalid table name:", aurora.Cyan(request.TableID)) - return &errorResponse - } - - if request.Sort != "time desc" { - logging.Error(moduleName, "Invalid sort string:", aurora.Cyan(request.Sort)) - return &errorResponse - } - - if request.Offset != 0 { - logging.Error(moduleName, "Invalid offset value:", aurora.Cyan(request.Offset)) - return &errorResponse - } - - if request.Max != 1 { - logging.Error(moduleName, "Invalid number of records to return:", aurora.Cyan(request.Max)) - return &errorResponse - } - - if request.Surrounding != 0 { - logging.Error(moduleName, "Invalid number of surrounding records to return:", aurora.Cyan(request.Surrounding)) - return &errorResponse - } - - if request.OwnerIDs != "" { - logging.Error(moduleName, "Invalid owner id array:", aurora.Cyan(request.OwnerIDs)) - return &errorResponse - } - - if request.CacheFlag != 0 { - logging.Error(moduleName, "Invalid cache value:", aurora.Cyan(request.CacheFlag)) - return &errorResponse - } - - match := regexp.MustCompile(`^course = ([1-9]\d?|0) and gameid = 1687 and time < ([1-9][0-9]{0,5})$`).FindStringSubmatch(request.Filter) - if match == nil { - logging.Error(moduleName, "Invalid filter string:", aurora.Cyan(request.Filter)) - return &errorResponse - } - - courseIdInt, _ := strconv.Atoi(match[1]) - courseId := common.MarioKartWiiCourseId(courseIdInt) - if !courseId.IsValid() { - logging.Error(moduleName, "Invalid course ID:", aurora.Cyan(match[1])) - return &errorResponse - } - - time, _ := strconv.Atoi(match[2]) - if time >= 360000 /* 6 minutes */ { - logging.Error(moduleName, "Invalid time:", aurora.Cyan(match[2])) - return &errorResponse - } - - fileId, err := database.GetMarioKartWiiGhostData(pool, ctx, courseId, time) - if err != nil { - logging.Error(moduleName, "Failed to get the ghost data from the database:", err) - return &errorResponse - } - - values = []map[string]StorageValue{ - { - "fileid": intValue(int32(fileId)), - }, - } - - case "mariokartwii/StoredGhostData": - if request.Sort != "time" { - logging.Error(moduleName, "Invalid sort string:", aurora.Cyan(request.Sort)) - return &errorResponse - } - - if request.Offset != 0 { - logging.Error(moduleName, "Invalid offset value:", aurora.Cyan(request.Offset)) - return &errorResponse - } - - if request.Max != 1 { - logging.Error(moduleName, "Invalid number of records to return:", aurora.Cyan(request.Max)) - return &errorResponse - } - - if request.Surrounding != 0 { - logging.Error(moduleName, "Invalid number of surrounding records to return:", aurora.Cyan(request.Surrounding)) - return &errorResponse - } - - if request.OwnerIDs != "" { - logging.Error(moduleName, "Invalid owner id array:", aurora.Cyan(request.OwnerIDs)) - return &errorResponse - } - - if request.CacheFlag != 0 { - logging.Error(moduleName, "Invalid cache value:", aurora.Cyan(request.CacheFlag)) - return &errorResponse - } - - match := regexp.MustCompile(`^course = ([1-9]\d?|0) and gameid = 1687(?: and region = ([1-7]))?$`).FindStringSubmatch(request.Filter) - if match == nil { - logging.Error(moduleName, "Invalid filter string:", aurora.Cyan(request.Filter)) - return &errorResponse - } - - courseIdInt, _ := strconv.Atoi(match[1]) - courseId := common.MarioKartWiiCourseId(courseIdInt) - if !courseId.IsValid() { - logging.Error(moduleName, "Invalid course ID:", aurora.Cyan(match[1])) - return &errorResponse - } - - var regionId common.MarioKartWiiLeaderboardRegionId - if regionIdExists := match[2] != ""; regionIdExists { - regionIdInt, _ := strconv.Atoi(match[2]) - regionId = common.MarioKartWiiLeaderboardRegionId(regionIdInt) - } else { - regionId = common.Worldwide - } - - pid, fileId, err := database.GetMarioKartWiiStoredGhostData(pool, ctx, regionId, courseId) - if err != nil { - logging.Error(moduleName, "Failed to get the stored ghost data from the database:", err) - return &errorResponse - } - - values = []map[string]StorageValue{ - { - "profile": intValue(int32(pid)), - "fileid": intValue(int32(fileId)), - }, + logging.Error(moduleName, "Failed to get sake records from the database:", err) + return StorageResponseBody{SearchForRecordsResponse: &StorageSearchForRecordsResponse{ + SearchForRecordsResult: ResultDatabaseUnavailable, + }} } } - // Sort the values now - sort.Slice(values, func(l, r int) bool { - lVal, lExists := values[l][request.Sort] - rVal, rExists := values[r][request.Sort] + // Sort the records now + sort.Slice(records, func(l, r int) bool { + lVal, lExists := records[l].Fields[request.Sort] + rVal, rExists := records[r].Fields[request.Sort] if !lExists || !rExists { // Prioritises the one that exists or goes left if both false return rExists } - if lVal.XMLName.Local != "intValue" && lVal.XMLName.Local != "uintValue" { - panic(aurora.Cyan(lVal.XMLName.Local).String() + " used as sort value") + if lVal.Type != database.SakeFieldTypeInt || rVal.Type != database.SakeFieldTypeInt { + panic(aurora.Cyan(lVal.Type).String() + " used as sort value") } - // Assuming the two use the same type lValInt, err := strconv.ParseInt(lVal.Value, 10, 64) if err != nil { @@ -497,24 +465,79 @@ func searchForRecords(moduleName string, gameInfo common.GameInfo, request Stora return lValInt < rValInt }) + // Enforce the maximum number of records after sorting + if request.Max > 0 && len(records) > request.Max { + records = records[:request.Max] + } + response := StorageSearchForRecordsResponse{ SearchForRecordsResult: "Success", + Values: fillResponseValues(records, request), } + logging.Info(moduleName, "Searched for records in table", aurora.Cyan(request.TableID), "for profile", aurora.Cyan(profileId), "with filter", aurora.Cyan(request.Filter), "and got", aurora.Cyan(len(records)), "records") - fieldCount := 0 - valueArray := &response.Values.ArrayOfRecordValue - var i int - for i = 0; i < len(values) && i < request.Max; i++ { + return StorageResponseBody{SearchForRecordsResponse: &response} +} + +func fillResponseValues(records []database.SakeRecord, request StorageRequestData) StorageResponseValues { + var response StorageResponseValues + for _, record := range records { + valueArray := StorageArrayOfRecordValue{} for _, field := range request.Fields.Fields { - if value, ok := values[i][field]; ok { - fieldCount++ - valueArray.RecordValues = append(valueArray.RecordValues, StorageRecordValue{Value: &value}) - } else { + if field == "ownerid" { + valueArray.RecordValues = append(valueArray.RecordValues, StorageRecordValue{Value: &StorageValue{ + XMLName: xml.Name{Local: "intValue"}, + Value: strconv.FormatInt(int64(int32(record.OwnerId)), 10), + }}) + continue + } + if field == "recordid" { + valueArray.RecordValues = append(valueArray.RecordValues, StorageRecordValue{Value: &StorageValue{ + XMLName: xml.Name{Local: "intValue"}, + Value: strconv.FormatInt(int64(int32(record.RecordId)), 10), + }}) + continue + } + + var fieldValue *database.SakeField + for name, value := range record.Fields { + if name == field { + fieldValue = &value + break + } + } + if fieldValue == nil { valueArray.RecordValues = append(valueArray.RecordValues, StorageRecordValue{Value: nil}) + } else { + value := fillValue(fieldValue.Type, fieldValue.Value) + valueArray.RecordValues = append(valueArray.RecordValues, StorageRecordValue{Value: &value}) } } + response.ArrayOfRecordValue = append(response.ArrayOfRecordValue, valueArray) + } + return response +} + +func fillValue(valueType database.SakeFieldType, value string) StorageValue { + return StorageValue{ + XMLName: xml.Name{Local: sakeTypeToTag[valueType]}, + Value: value, + } +} + +func (body *StorageResponseBody) setResultTag(xmlName string, result string) { + switch xmlName { + case SakeNamespace + "/GetMyRecords": + body.GetMyRecordsResponse = &StorageGetMyRecordsResponse{ + GetMyRecordsResult: result, + } + + case SakeNamespace + "/UpdateRecord": + body.UpdateRecordResponse = &StorageUpdateRecordResponse{ + UpdateRecordResult: result, + } + + case SakeNamespace + "/SearchForRecords": + body.SearchForRecordsResponse = &StorageSearchForRecordsResponse{} } - - logging.Info(moduleName, "Wrote", aurora.BrightCyan(fieldCount), "field(s) across", aurora.BrightCyan(i), "record(s)") - return &response } diff --git a/schema.sql b/schema.sql index d040cab..4bb4250 100644 --- a/schema.sql +++ b/schema.sql @@ -63,6 +63,21 @@ END $$; ALTER TABLE public.users OWNER TO wiilink; +-- +-- Name: sake_records; Type: TABLE; Schema: public; Owner: wiilink +-- +CREATE TABLE IF NOT EXISTS public.sake_records ( + game_id integer NOT NULL, + table_id character varying NOT NULL, + record_id integer NOT NULL DEFAULT (random() * 2147483647)::integer, + owner_id integer NOT NULL, + fields jsonb NOT NULL, + create_time timestamp without time zone DEFAULT CURRENT_TIMESTAMP, + update_time timestamp without time zone DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT one_sake_record_constraint UNIQUE (game_id, table_id, record_id) +); + -- -- Name: mario_kart_wii_sake; Type: TABLE; Schema: public; Owner: wiilink --