Compare commits

...

7 commits
v2.2.1 ... main

Author SHA1 Message Date
Daniel López Guimaraes
db917c2172
Merge pull request #49 from PretendoNetwork/notification-data 2025-02-24 23:44:39 +00:00
Daniel López Guimaraes
52d289e761
fix(matchmake-extension): Fix notification string length check
Kid Icarus: Uprising sends strings with byte length longer than 156, so
assume this should count runes instead.
2025-02-24 23:34:32 +00:00
Daniel López Guimaraes
9005d07605
fix(matchmake-extension): Fix OnAfter typo on notification data methods 2025-02-24 22:30:05 +00:00
Daniel López Guimaraes
0fe4724b02 fix(matchmake-extension): Send notification data to connected friends
Removes warnings for connections not being found when it isn't relevant.
2025-02-13 15:22:04 +00:00
Daniel López Guimaraes
ffaba6e616 fix(matchmake-extension): Fix incorrect SQL query for notifications
Also improve and fix the NotificationData methods.
2025-02-13 15:22:04 +00:00
Daniel López Guimaraes
1450b7ffcc feat(matchmake-extension): Implement NotificationData methods
The NotificationData methods are used by games to send notifications to
friends about user activity, among others. These notifications are
created or updated using `UpdateNotificationData`, which the server will
register and send to the connected friends (as seen on Mario Tennis Open).

The lifetime of these notifications is the same as the connection of the
user who sends them. That is, when a user sends a notification and then
disconnects, the notifications will be discarded.

All notifications sent over `UpdateNotificationData` are logged inside
the `tracking.notification_data` table to prevent abuse. The type of
these notifications is also constrained to a range of specific values
reserved for game-specific purposes (from 101 to 108).
2025-02-13 15:22:04 +00:00
Daniel López Guimaraes
f83d9061ee
fix(ranking): Fix inlined DateTime declaration
Instead of instantiating a new DateTime we can use the one that already
exists on the result param.
2025-02-11 22:41:48 +00:00
10 changed files with 413 additions and 3 deletions

View file

@ -0,0 +1,60 @@
package database
import (
"github.com/PretendoNetwork/nex-go/v2"
"github.com/PretendoNetwork/nex-go/v2/types"
common_globals "github.com/PretendoNetwork/nex-protocols-common-go/v2/globals"
notifications_types "github.com/PretendoNetwork/nex-protocols-go/v2/notifications/types"
pqextended "github.com/PretendoNetwork/pq-extended"
)
// GetNotificationDatas gets the notification datas that belong to friends of the user and match with any of the given types
func GetNotificationDatas(manager *common_globals.MatchmakingManager, sourcePID types.PID, notificationTypes []uint32) ([]notifications_types.NotificationEvent, *nex.Error) {
dataList := make([]notifications_types.NotificationEvent, 0)
var friendList []uint32
if manager.GetUserFriendPIDs != nil {
friendList = manager.GetUserFriendPIDs(uint32(sourcePID))
} else {
common_globals.Logger.Warning("GetNotificationDatas missing manager.GetUserFriendPIDs!")
}
// * No friends to check
if len(friendList) == 0 {
return dataList, nil
}
rows, err := manager.Database.Query(`SELECT
source_pid,
type,
param_1,
param_2,
param_str
FROM matchmaking.notifications WHERE active=true AND source_pid=ANY($1) AND type=ANY($2)
`, pqextended.Array(friendList), pqextended.Array(notificationTypes))
if err != nil {
return nil, nex.NewError(nex.ResultCodes.Core.Unknown, err.Error())
}
for rows.Next() {
notificationData := notifications_types.NewNotificationEvent()
err = rows.Scan(
&notificationData.PIDSource,
&notificationData.Type,
&notificationData.Param1,
&notificationData.Param2,
&notificationData.StrParam,
)
if err != nil {
common_globals.Logger.Critical(err.Error())
continue
}
dataList = append(dataList, notificationData)
}
rows.Close()
return dataList, nil
}

View file

@ -0,0 +1,18 @@
package database
import (
"github.com/PretendoNetwork/nex-go/v2"
"github.com/PretendoNetwork/nex-go/v2/types"
common_globals "github.com/PretendoNetwork/nex-protocols-common-go/v2/globals"
)
// InactivateNotificationDatas marks the notifications of a given user as inactive
func InactivateNotificationDatas(manager *common_globals.MatchmakingManager, sourcePID types.PID) *nex.Error {
_, err := manager.Database.Exec(`UPDATE matchmaking.notifications SET active=false WHERE source_pid=$1`, sourcePID)
if err != nil {
common_globals.Logger.Error(err.Error())
return nex.NewError(nex.ResultCodes.Core.Unknown, err.Error())
}
return nil
}

View file

@ -0,0 +1,36 @@
package database
import (
"github.com/PretendoNetwork/nex-go/v2"
notifications_types "github.com/PretendoNetwork/nex-protocols-go/v2/notifications/types"
common_globals "github.com/PretendoNetwork/nex-protocols-common-go/v2/globals"
)
// UpdateNotificationData updates the notification data of the specified user and type
func UpdateNotificationData(manager *common_globals.MatchmakingManager, notificationData notifications_types.NotificationEvent) *nex.Error {
_, err := manager.Database.Exec(`INSERT INTO matchmaking.notifications AS n (
source_pid,
type,
param_1,
param_2,
param_str
) VALUES (
$1,
$2,
$3,
$4,
$5
) ON CONFLICT (source_pid, type) DO UPDATE SET
param_1=$3, param_2=$4, param_str=$5, active=true WHERE n.source_pid=$1 AND n.type=$2`,
notificationData.PIDSource,
notificationData.Type,
notificationData.Param1,
notificationData.Param2,
notificationData.StrParam,
)
if err != nil {
return nex.NewError(nex.ResultCodes.Core.Unknown, err.Error())
}
return nil
}

View file

@ -0,0 +1,56 @@
package matchmake_extension
import (
"github.com/PretendoNetwork/nex-go/v2"
"github.com/PretendoNetwork/nex-go/v2/types"
common_globals "github.com/PretendoNetwork/nex-protocols-common-go/v2/globals"
"github.com/PretendoNetwork/nex-protocols-common-go/v2/matchmake-extension/database"
matchmake_extension "github.com/PretendoNetwork/nex-protocols-go/v2/matchmake-extension"
notifications "github.com/PretendoNetwork/nex-protocols-go/v2/notifications"
notifications_types "github.com/PretendoNetwork/nex-protocols-go/v2/notifications/types"
)
func (commonProtocol *CommonProtocol) getFriendNotificationData(err error, packet nex.PacketInterface, callID uint32, uiType types.Int32) (*nex.RMCMessage, *nex.Error) {
if err != nil {
common_globals.Logger.Error(err.Error())
return nil, nex.NewError(nex.ResultCodes.Core.InvalidArgument, err.Error())
}
// * This method can only receive notifications within the range 101-108, which are reserved for game-specific notifications
if uiType < 101 || uiType > 108 {
return nil, nex.NewError(nex.ResultCodes.Core.InvalidArgument, "change_error")
}
connection := packet.Sender().(*nex.PRUDPConnection)
endpoint := connection.Endpoint().(*nex.PRUDPEndPoint)
commonProtocol.manager.Mutex.RLock()
notificationDatas, nexError := database.GetNotificationDatas(commonProtocol.manager, connection.PID(), []uint32{notifications.BuildNotificationType(uint32(uiType), 0)})
if nexError != nil {
commonProtocol.manager.Mutex.RUnlock()
return nil, nexError
}
commonProtocol.manager.Mutex.RUnlock()
dataList := types.NewList[notifications_types.NotificationEvent]()
dataList = notificationDatas
rmcResponseStream := nex.NewByteStreamOut(endpoint.LibraryVersions(), endpoint.ByteStreamSettings())
dataList.WriteTo(rmcResponseStream)
rmcResponseBody := rmcResponseStream.Bytes()
rmcResponse := nex.NewRMCSuccess(endpoint, rmcResponseBody)
rmcResponse.ProtocolID = matchmake_extension.ProtocolID
rmcResponse.MethodID = matchmake_extension.MethodGetFriendNotificationData
rmcResponse.CallID = callID
if commonProtocol.OnAfterGetFriendNotificationData != nil {
go commonProtocol.OnAfterGetFriendNotificationData(packet, uiType)
}
return rmcResponse, nil
}

View file

@ -0,0 +1,61 @@
package matchmake_extension
import (
"github.com/PretendoNetwork/nex-go/v2"
"github.com/PretendoNetwork/nex-go/v2/types"
matchmake_extension "github.com/PretendoNetwork/nex-protocols-go/v2/matchmake-extension"
notifications "github.com/PretendoNetwork/nex-protocols-go/v2/notifications"
notifications_types "github.com/PretendoNetwork/nex-protocols-go/v2/notifications/types"
common_globals "github.com/PretendoNetwork/nex-protocols-common-go/v2/globals"
"github.com/PretendoNetwork/nex-protocols-common-go/v2/matchmake-extension/database"
)
func (commonProtocol *CommonProtocol) getlstFriendNotificationData(err error, packet nex.PacketInterface, callID uint32, lstTypes types.List[types.UInt32]) (*nex.RMCMessage, *nex.Error) {
if err != nil {
common_globals.Logger.Error(err.Error())
return nil, nex.NewError(nex.ResultCodes.Core.InvalidArgument, err.Error())
}
connection := packet.Sender().(*nex.PRUDPConnection)
endpoint := connection.Endpoint().(*nex.PRUDPEndPoint)
notificationTypes := make([]uint32, len(lstTypes))
for i, notificationType := range lstTypes {
// * This method can only receive notifications within the range 101-108, which are reserved for game-specific notifications
if notificationType < 101 || notificationType > 108 {
return nil, nex.NewError(nex.ResultCodes.Core.InvalidArgument, "change_error")
}
notificationTypes[i] = notifications.BuildNotificationType(uint32(notificationType), 0)
}
commonProtocol.manager.Mutex.RLock()
notificationDatas, nexError := database.GetNotificationDatas(commonProtocol.manager, connection.PID(), notificationTypes)
if nexError != nil {
commonProtocol.manager.Mutex.RUnlock()
return nil, nexError
}
commonProtocol.manager.Mutex.RUnlock()
dataList := types.NewList[notifications_types.NotificationEvent]()
dataList = notificationDatas
rmcResponseStream := nex.NewByteStreamOut(endpoint.LibraryVersions(), endpoint.ByteStreamSettings())
dataList.WriteTo(rmcResponseStream)
rmcResponseBody := rmcResponseStream.Bytes()
rmcResponse := nex.NewRMCSuccess(endpoint, rmcResponseBody)
rmcResponse.ProtocolID = matchmake_extension.ProtocolID
rmcResponse.MethodID = matchmake_extension.MethodGetlstFriendNotificationData
rmcResponse.CallID = callID
if commonProtocol.OnAfterGetlstFriendNotificationData != nil {
go commonProtocol.OnAfterGetlstFriendNotificationData(packet, lstTypes)
}
return rmcResponse, nil
}

View file

@ -38,6 +38,9 @@ type CommonProtocol struct {
OnAfterBrowseMatchmakeSession func(packet nex.PacketInterface, searchCriteria match_making_types.MatchmakeSessionSearchCriteria, resultRange types.ResultRange)
OnAfterJoinMatchmakeSessionEx func(packet nex.PacketInterface, gid types.UInt32, strMessage types.String, dontCareMyBlockList types.Bool, participationCount types.UInt16)
OnAfterGetSimpleCommunity func(packet nex.PacketInterface, gatheringIDList types.List[types.UInt32])
OnAfterUpdateNotificationData func(packet nex.PacketInterface, uiType types.UInt32, uiParam1 types.UInt32, uiParam2 types.UInt32, strParam types.String)
OnAfterGetFriendNotificationData func(packet nex.PacketInterface, uiType types.Int32)
OnAfterGetlstFriendNotificationData func(packet nex.PacketInterface, lstTypes types.List[types.UInt32])
}
// SetDatabase defines the matchmaking manager to be used by the common protocol
@ -99,6 +102,21 @@ func (commonProtocol *CommonProtocol) SetManager(manager *common_globals.Matchma
return
}
_, err = manager.Database.Exec(`CREATE TABLE IF NOT EXISTS matchmaking.notifications (
id bigserial PRIMARY KEY,
source_pid numeric(10),
type bigint,
param_1 bigint,
param_2 bigint,
param_str text,
active boolean NOT NULL DEFAULT true,
UNIQUE (source_pid, type)
)`)
if err != nil {
common_globals.Logger.Error(err.Error())
return
}
_, err = manager.Database.Exec(`CREATE TABLE IF NOT EXISTS tracking.participate_community (
id bigserial PRIMARY KEY,
date timestamp,
@ -112,18 +130,41 @@ func (commonProtocol *CommonProtocol) SetManager(manager *common_globals.Matchma
return
}
_, err = manager.Database.Exec(`CREATE TABLE IF NOT EXISTS tracking.notification_data (
id bigserial PRIMARY KEY,
date timestamp,
source_pid numeric(10),
type bigint,
param_1 bigint,
param_2 bigint,
param_str text
)`)
if err != nil {
common_globals.Logger.Error(err.Error())
return
}
// * In case the server is restarted, unregister any previous matchmake sessions
_, err = manager.Database.Exec(`UPDATE matchmaking.gatherings SET registered=false WHERE type='MatchmakeSession'`)
if err != nil {
common_globals.Logger.Error(err.Error())
return
}
// * Mark all notifications as inactive
_, err = manager.Database.Exec(`UPDATE matchmaking.notifications SET active=false`)
if err != nil {
common_globals.Logger.Error(err.Error())
return
}
}
// NewCommonProtocol returns a new CommonProtocol
func NewCommonProtocol(protocol matchmake_extension.Interface) *CommonProtocol {
endpoint := protocol.Endpoint().(*nex.PRUDPEndPoint)
commonProtocol := &CommonProtocol{
endpoint: protocol.Endpoint(),
endpoint: endpoint,
protocol: protocol,
PersistentGatheringCreationMax: 4, // * Default of 4 active persistent gatherings per user
}
@ -148,6 +189,15 @@ func NewCommonProtocol(protocol matchmake_extension.Interface) *CommonProtocol {
protocol.SetHandlerBrowseMatchmakeSession(commonProtocol.browseMatchmakeSession)
protocol.SetHandlerJoinMatchmakeSessionEx(commonProtocol.joinMatchmakeSessionEx)
protocol.SetHandlerGetSimpleCommunity(commonProtocol.getSimpleCommunity)
protocol.SetHandlerUpdateNotificationData(commonProtocol.updateNotificationData)
protocol.SetHandlerGetFriendNotificationData(commonProtocol.getFriendNotificationData)
protocol.SetHandlerGetlstFriendNotificationData(commonProtocol.getlstFriendNotificationData)
endpoint.OnConnectionEnded(func(connection *nex.PRUDPConnection) {
commonProtocol.manager.Mutex.Lock()
database.InactivateNotificationDatas(commonProtocol.manager, connection.PID())
commonProtocol.manager.Mutex.Unlock()
})
return commonProtocol
}

View file

@ -0,0 +1,42 @@
package tracking
import (
"database/sql"
"time"
"github.com/PretendoNetwork/nex-go/v2"
notifications_types "github.com/PretendoNetwork/nex-protocols-go/v2/notifications/types"
)
// LogNotificationData logs the update of the notification data of a user with UpdateNotificationData
func LogNotificationData(db *sql.DB, notificationData notifications_types.NotificationEvent) *nex.Error {
eventTime := time.Now().UTC()
_, err := db.Exec(`INSERT INTO tracking.notification_data (
date,
source_pid,
type,
param_1,
param_2,
param_str
) VALUES (
$1,
$2,
$3,
$4,
$5,
$6
)`,
eventTime,
notificationData.PIDSource,
notificationData.Type,
notificationData.Param1,
notificationData.Param2,
notificationData.StrParam,
)
if err != nil {
return nex.NewError(nex.ResultCodes.Core.Unknown, err.Error())
}
return nil
}

View file

@ -0,0 +1,87 @@
package matchmake_extension
import (
"unicode/utf8"
"github.com/PretendoNetwork/nex-go/v2"
"github.com/PretendoNetwork/nex-go/v2/types"
common_globals "github.com/PretendoNetwork/nex-protocols-common-go/v2/globals"
"github.com/PretendoNetwork/nex-protocols-common-go/v2/matchmake-extension/database"
"github.com/PretendoNetwork/nex-protocols-common-go/v2/matchmake-extension/tracking"
matchmake_extension "github.com/PretendoNetwork/nex-protocols-go/v2/matchmake-extension"
notifications "github.com/PretendoNetwork/nex-protocols-go/v2/notifications"
notifications_types "github.com/PretendoNetwork/nex-protocols-go/v2/notifications/types"
)
func (commonProtocol *CommonProtocol) updateNotificationData(err error, packet nex.PacketInterface, callID uint32, uiType types.UInt32, uiParam1 types.UInt32, uiParam2 types.UInt32, strParam types.String) (*nex.RMCMessage, *nex.Error) {
if err != nil {
common_globals.Logger.Error(err.Error())
return nil, nex.NewError(nex.ResultCodes.Core.InvalidArgument, err.Error())
}
// * This method can only send notifications within the range 101-108, which are reserved for game-specific notifications
if uiType < 101 || uiType > 108 {
return nil, nex.NewError(nex.ResultCodes.Core.InvalidArgument, "change_error")
}
// * All strings must have a length lower than 256.
// * Kid Icarus: Uprising sends strings with UTF-8 bytes longer than 256, so I assume this should count the runes instead
if utf8.RuneCountInString(string(strParam)) > 256 {
return nil, nex.NewError(nex.ResultCodes.Core.InvalidArgument, "change_error")
}
connection := packet.Sender().(*nex.PRUDPConnection)
endpoint := connection.Endpoint().(*nex.PRUDPEndPoint)
notificationData := notifications_types.NewNotificationEvent()
notificationData.PIDSource = connection.PID()
notificationData.Type = types.NewUInt32(notifications.BuildNotificationType(uint32(uiType), 0))
notificationData.Param1 = uiParam1
notificationData.Param2 = uiParam2
notificationData.StrParam = strParam
commonProtocol.manager.Mutex.Lock()
nexError := database.UpdateNotificationData(commonProtocol.manager, notificationData)
if nexError != nil {
commonProtocol.manager.Mutex.Unlock()
return nil, nexError
}
nexError = tracking.LogNotificationData(commonProtocol.manager.Database, notificationData)
if nexError != nil {
commonProtocol.manager.Mutex.Unlock()
return nil, nexError
}
commonProtocol.manager.Mutex.Unlock()
// * If the friends are connected, try to send the notifications directly aswell. This is observed on Mario Tennis Open
var friendList []uint32
if commonProtocol.manager.GetUserFriendPIDs != nil {
friendList = commonProtocol.manager.GetUserFriendPIDs(uint32(connection.PID()))
}
if len(friendList) != 0 {
var targets []uint64
for _, pid := range friendList {
// * Only send the notification to friends who are connected
if endpoint.FindConnectionByPID(uint64(pid)) != nil {
targets = append(targets, uint64(pid))
}
}
common_globals.SendNotificationEvent(endpoint, notificationData, targets)
}
rmcResponse := nex.NewRMCSuccess(endpoint, nil)
rmcResponse.ProtocolID = matchmake_extension.ProtocolID
rmcResponse.MethodID = matchmake_extension.MethodUpdateNotificationData
rmcResponse.CallID = callID
if commonProtocol.OnAfterUpdateNotificationData != nil {
go commonProtocol.OnAfterUpdateNotificationData(packet, uiType, uiParam1, uiParam2, strParam)
}
return rmcResponse, nil
}

View file

@ -45,7 +45,7 @@ func (commonProtocol *CommonProtocol) getCachedTopXRanking(err error, packet nex
// * It doesn't change, even on subsequent requests, until after the
// * ExpiredTime has passed (seemingly what the "cached" means).
// * Whether we need to replicate this idk, but in case, here's a note.
pResult.ExpiredTime = types.NewDateTime(0).FromTimestamp(time.Now().UTC().Add(time.Minute * time.Duration(5)))
pResult.ExpiredTime.FromTimestamp(time.Now().UTC().Add(time.Minute * time.Duration(5)))
// * This is the length Ultimate NES Remix uses
// TODO - Does this matter? and are other games different?
pResult.MaxLength = types.NewUInt8(10)

View file

@ -56,7 +56,7 @@ func (commonProtocol *CommonProtocol) getCachedTopXRankings(err error, packet ne
// * It doesn't change, even on subsequent requests, until after the
// * ExpiredTime has passed (seemingly what the "cached" means).
// * Whether we need to replicate this idk, but in case, here's a note.
result.ExpiredTime = types.NewDateTime(0).FromTimestamp(time.Now().UTC().Add(time.Minute * time.Duration(5)))
result.ExpiredTime.FromTimestamp(time.Now().UTC().Add(time.Minute * time.Duration(5)))
// * This is the length Ultimate NES Remix uses
// TODO - Does this matter? and are other games different?
result.MaxLength = types.NewUInt8(10)