Implement friends list

This commit is contained in:
Melledy
2025-11-14 04:39:48 -08:00
parent 1c52ec011f
commit ecc2ef2315
15 changed files with 666 additions and 10 deletions

View File

@@ -101,7 +101,7 @@ public class Config {
public boolean skipIntro = false;
public boolean unlockInstances = true;
public int dailyResetHour = 0;
public int leaderboardRefreshTime = 60;
public int leaderboardRefreshTime = 60; // Leaderboard refresh time in seconds
public WelcomeMail welcomeMail = new WelcomeMail();
}

View File

@@ -24,4 +24,7 @@ public class GameConstants {
public static final int MAX_SHOWCASE_IDS = 5;
public static final int BATTLE_PASS_ID = 1;
public static final int MAX_FRIENDSHIPS = 50;
public static final int MAX_PENDING_FRIENDSHIPS = 30;
}

View File

@@ -0,0 +1,311 @@
package emu.nebula.game.friends;
import java.util.ArrayList;
import java.util.List;
import emu.nebula.GameConstants;
import emu.nebula.Nebula;
import emu.nebula.game.GameContext;
import emu.nebula.game.player.Player;
import emu.nebula.game.player.PlayerManager;
import emu.nebula.net.NetMsgId;
import emu.nebula.proto.FriendListGet.FriendListGetResp;
import emu.nebula.proto.Public.FriendDetail;
import emu.nebula.proto.Public.FriendState;
import it.unimi.dsi.fastutil.ints.Int2ObjectMap;
import it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap;
import lombok.Getter;
@Getter
public class FriendList extends PlayerManager {
private final Int2ObjectMap<Friendship> friends;
private final Int2ObjectMap<Friendship> pendingFriends;
private long cacheCooldown;
private FriendListGetResp cachedProto;
public FriendList(Player player) {
super(player);
this.friends = new Int2ObjectOpenHashMap<Friendship>();
this.pendingFriends = new Int2ObjectOpenHashMap<Friendship>();
}
private GameContext getGameContext() {
return Nebula.getGameContext();
}
public boolean isLoaded() {
return this.getPlayer().isLoaded();
}
private synchronized Friendship getFriendById(int id) {
if (this.isLoaded()) {
return this.getFriends().get(id);
} else {
return Nebula.getGameDatabase().getObjectByUid(Friendship.class, Friendship.generateUniqueKey(getPlayerUid(), id));
}
}
private synchronized Friendship getPendingFriendById(int id) {
if (this.isLoaded()) {
return this.getPendingFriends().get(id);
} else {
return Nebula.getGameDatabase().getObjectByUid(Friendship.class, Friendship.generateUniqueKey(getPlayerUid(), id));
}
}
public synchronized boolean hasPendingRequests() {
return this.getPendingFriends().values()
.stream()
.filter(f -> f.getAskerUid() != this.getPlayerUid())
.findAny()
.isPresent();
}
private void addFriendship(Friendship friendship) {
getFriends().put(friendship.getFriendUid(), friendship);
this.cacheCooldown = 0;
}
private void addPendingFriendship(Friendship friendship) {
getPendingFriends().put(friendship.getFriendUid(), friendship);
this.cacheCooldown = 0;
}
private void removeFriendship(int uid) {
getFriends().remove(uid);
this.cacheCooldown = 0;
}
private void removePendingFriendship(int uid) {
getPendingFriends().remove(uid);
this.cacheCooldown = 0;
}
/**
* Gets total amount of potential friends
*/
public int getFullFriendCount() {
return this.getPendingFriends().size() + this.getFriends().size();
}
public synchronized Player handleFriendRequest(int targetUid, boolean action) {
// Make sure we have enough room
if (this.getFriends().size() >= GameConstants.MAX_FRIENDSHIPS) {
return null;
}
// Check if player has sent friend request
Friendship myFriendship = this.getPendingFriendById(targetUid);
if (myFriendship == null) {
return null;
}
// Make sure this player is not the asker
if (myFriendship.getAskerUid() == this.getPlayer().getUid()) {
return null;
}
// Get target player
Player target = getGameContext().getPlayerModule().getPlayer(targetUid);
if (target == null) return null;
// Get target player's friendship
Friendship theirFriendship = target.getFriendList().getPendingFriendById(getPlayer().getUid());
if (theirFriendship == null) {
// They dont have us on their friends list anymore, rip
this.removePendingFriendship(target.getUid());
myFriendship.delete();
return null;
}
// Handle action
if (action) {
// Request accepted
myFriendship.setFriend(true);
theirFriendship.setFriend(true);
this.removePendingFriendship(myFriendship.getFriendUid());
this.addFriendship(myFriendship);
if (target.isLoaded()) {
target.getFriendList().removePendingFriendship(this.getPlayer().getUid());
target.getFriendList().addFriendship(theirFriendship);
}
// Save friendships to the database
myFriendship.save();
theirFriendship.save();
} else {
// Request declined - Delete from my pending friends
this.removePendingFriendship(myFriendship.getOwnerUid());
if (target.isLoaded()) {
target.getFriendList().removePendingFriendship(getPlayer().getUid());
}
// Delete friendships from the database
myFriendship.delete();
theirFriendship.delete();
}
// Success
return target;
}
public List<Player> acceptAll() {
// Results
List<Player> results = new ArrayList<>();
// Get list of friendships to accept
List<Friendship> list = getPendingFriends().values()
.stream()
.toList();
for (var invite : list) {
var target = this.handleFriendRequest(invite.getFriendUid(), true);
if (target != null) {
results.add(target);
}
}
return results;
}
public synchronized boolean sendFriendRequest(int targetUid) {
// Get target and sanity check
Player target = getGameContext().getPlayerModule().getPlayer(targetUid);
if (target == null || target == this.getPlayer()) {
return false;
}
// Check if friend already exists
if (getPendingFriends().containsKey(targetUid) || getFriends().containsKey(targetUid)) {
return false;
}
// Create friendships
Friendship myFriendship = new Friendship(getPlayer(), target, getPlayer());
Friendship theirFriendship = new Friendship(target, getPlayer(), getPlayer());
// Add to our pending friendship list
this.addPendingFriendship(myFriendship);
if (target.isLoaded()) {
target.getFriendList().addPendingFriendship(theirFriendship);
// Send message to notify target
target.addNextPackage(
NetMsgId.friend_state_notify,
FriendState.newInstance()
.setId(this.getPlayerUid())
.setAction(1)
);
}
// Save friendships to the database
myFriendship.save();
theirFriendship.save();
// Success
return true;
}
public synchronized boolean deleteFriend(int targetUid) {
// Get friendship
Friendship myFriendship = this.getFriendById(targetUid);
if (myFriendship == null) return false;
// Remove from friends list
this.removeFriendship(targetUid);
myFriendship.delete();
// Delete from friend's friend list
Player friend = getGameContext().getPlayerModule().getPlayer(targetUid);
if (friend != null) {
// Friend online
Friendship theirFriendship = friend.getFriendList().getFriendById(this.getPlayer().getUid());
if (theirFriendship != null) {
// Delete friendship on friends side
theirFriendship.delete();
if (friend.isLoaded()) {
// Remove from online friend's friend list
friend.getFriendList().removeFriendship(theirFriendship.getFriendUid());
}
}
}
// Success
return true;
}
// Database
public synchronized void loadFromDatabase() {
var friendships = Nebula.getGameDatabase().getObjects(Friendship.class, "ownerUid", this.getPlayer().getUid());
friendships.forEach(friendship -> {
// Set ownership first
friendship.setOwner(getPlayer());
// Finally, load to our friends list
if (friendship.isFriend()) {
getFriends().put(friendship.getFriendUid(), friendship);
} else {
getPendingFriends().put(friendship.getFriendUid(), friendship);
}
});
}
// Proto
public synchronized FriendListGetResp toProto() {
if (this.cachedProto == null || System.currentTimeMillis() > this.cacheCooldown) {
this.cachedProto = this.updateCache();
this.cacheCooldown = System.currentTimeMillis() + 60_000;
}
return this.cachedProto;
}
private FriendListGetResp updateCache() {
var proto = FriendListGetResp.newInstance();
// Encode friends list
for (var friend : getFriends().values()) {
// Get base friend info
var base = friend.toProto();
if (base == null) continue;
// Create info
var info = FriendDetail.newInstance()
.setBase(base)
.setGetEnergy(friend.getEnergy());
// Add
proto.addFriends(info);
}
// Encode pending invites
for (var friend : getPendingFriends().values()) {
// Skip if this is us
if (friend.getAskerUid() == this.getPlayerUid()) {
continue;
}
// Get base friend info
var base = friend.toProto();
if (base == null) continue;
// Add
proto.addInvites(base);
}
return proto;
}
}

View File

@@ -0,0 +1,65 @@
package emu.nebula.game.friends;
import dev.morphia.annotations.Entity;
import dev.morphia.annotations.Id;
import dev.morphia.annotations.Indexed;
import emu.nebula.Nebula;
import emu.nebula.database.GameDatabaseObject;
import emu.nebula.game.player.Player;
import emu.nebula.proto.Public.Friend;
import lombok.Getter;
import lombok.Setter;
@Getter
@Entity(value = "friendships", useDiscriminator = false)
public class Friendship implements GameDatabaseObject {
@Id private long key;
@Indexed private int ownerUid;
@Indexed private int friendUid;
private int askerUid;
@Setter
private boolean isFriend;
private boolean star;
private int energy;
@Setter private transient Player owner;
@Deprecated // Morphia use only
public Friendship() { }
public Friendship(Player owner, Player friend, Player asker) {
this.owner = owner;
this.ownerUid = owner.getUid();
this.friendUid = friend.getUid();
this.askerUid = asker.getUid();
this.key = Friendship.generateUniqueKey(owner.getUid(), friend.getUid());
}
// Database functions
public void delete() {
Nebula.getGameDatabase().delete(this);
}
// Proto
public Friend toProto() {
// Get target player
var target = Nebula.getGameContext().getPlayerModule().getPlayer(this.getFriendUid());
if (target == null) return null;
// Encode player to simple friend proto
return target.getFriendProto();
}
// Extra
/**
* Creates an unique key for a friendship object using 2 player uids
*/
public static long generateUniqueKey(int ownerUid, int targetUid) {
return ((long) ownerUid << 32) + targetUid;
}
}

View File

@@ -15,6 +15,7 @@ import emu.nebula.game.agent.AgentManager;
import emu.nebula.game.battlepass.BattlePassManager;
import emu.nebula.game.character.CharacterStorage;
import emu.nebula.game.formation.FormationManager;
import emu.nebula.game.friends.FriendList;
import emu.nebula.game.gacha.GachaManager;
import emu.nebula.game.infinitytower.InfinityTowerManager;
import emu.nebula.game.instance.InstanceManager;
@@ -34,6 +35,7 @@ import emu.nebula.proto.PlayerData.DictionaryTab;
import emu.nebula.proto.PlayerData.PlayerInfo;
import emu.nebula.proto.Public.CharShow;
import emu.nebula.proto.Public.Energy;
import emu.nebula.proto.Public.Friend;
import emu.nebula.proto.Public.HonorInfo;
import emu.nebula.proto.Public.NewbieInfo;
import emu.nebula.proto.Public.QuestType;
@@ -73,10 +75,12 @@ public class Player implements GameDatabaseObject {
private long energyLastUpdate;
private long lastEpochDay;
private long lastLogin;
private long createTime;
// Managers
private final transient CharacterStorage characters;
private final transient FriendList friendList;
private final transient GachaManager gachaManager;
private final transient BattlePassManager battlePassManager;
private final transient StarTowerManager starTowerManager;
@@ -94,13 +98,15 @@ public class Player implements GameDatabaseObject {
private transient QuestManager questManager;
private transient AgentManager agentManager;
// Next packages
// Extra
private transient Stack<NetMsgPacket> nextPackages;
private transient boolean loaded;
@Deprecated // Morphia only
public Player() {
// Init player managers
this.characters = new CharacterStorage(this);
this.friendList = new FriendList(this);
this.gachaManager = new GachaManager(this);
this.battlePassManager = new BattlePassManager(this);
this.starTowerManager = new StarTowerManager(this);
@@ -126,6 +132,7 @@ public class Player implements GameDatabaseObject {
// Set basic info
this.accountUid = account.getUid();
this.createTime = Nebula.getCurrentTime();
this.name = name;
this.signature = "";
this.gender = gender;
@@ -576,6 +583,7 @@ public class Player implements GameDatabaseObject {
public void onLoad() {
// Load from database
this.getCharacters().loadFromDatabase();
this.getFriendList().loadFromDatabase();
this.getStarTowerManager().loadFromDatabase();
this.getBattlePassManager().loadFromDatabase();
@@ -598,6 +606,9 @@ public class Player implements GameDatabaseObject {
this.showChars = new int[3];
this.save();
}
// Load complete
this.loaded = true;
}
public void onLogin() {
@@ -606,6 +617,10 @@ public class Player implements GameDatabaseObject {
// Trigger quest login
this.triggerQuest(QuestCondType.LoginTotal, 1);
// Update last login time
this.lastLogin = System.currentTimeMillis();
Nebula.getGameDatabase().update(this, this.getUid(), "lastLogin", this.getLastLogin());
}
// Next packages
@@ -696,7 +711,8 @@ public class Player implements GameDatabaseObject {
// Set player states
var state = proto.getMutableState()
.setStorySet(true);
.setStorySet(true)
.setFriend(this.getFriendList().hasPendingRequests());
state.getMutableMail()
.setNew(this.getMailbox().hasNewMail());
@@ -791,6 +807,36 @@ public class Player implements GameDatabaseObject {
return proto;
}
public Friend getFriendProto() {
var proto = Friend.newInstance()
.setId(this.getUid())
.setWorldClass(this.getLevel())
.setHeadIcon(this.getHeadIcon())
.setNickName(this.getName())
.setSignature(this.getSignature())
.setTitlePrefix(this.getTitlePrefix())
.setTitleSuffix(this.getTitleSuffix())
.setLastLoginTime(this.getLastLogin() * 1_000_000L);
for (int charId : this.getShowChars()) {
var info = CharShow.newInstance()
.setCharId(charId)
.setLevel(1) // TODO
.setSkin((charId * 100) + 1); // TODO
proto.addCharShows(info);
}
for (int honorId : this.getHonor()) {
var info = HonorInfo.newInstance()
.setId(honorId);
proto.addHonors(info);
}
return proto;
}
public Energy getEnergyProto() {
long nextDuration = Math.max(GameConstants.ENERGY_REGEN_TIME - (Nebula.getCurrentTime() - getEnergyLastUpdate()), 1);

View File

@@ -49,12 +49,30 @@ public class PlayerModule extends GameContextModule {
return getCachedPlayers().get(uid);
}
/**
* Returns a player object with the given uid. Returns null if the player doesnt exist.
* Warning: Does NOT cache or load the playerdata if the player was loaded from the database.
* @param uid User id of the player
* @return
*/
public synchronized Player getPlayer(int uid) {
// Get player from cache
Player player = this.cachedPlayers.get(uid);
if (player == null) {
// Retrieve player object from database if its not there
player = Nebula.getGameDatabase().getObjectByUid(Player.class, uid);
}
return player;
}
/**
* Returns a player object with the given account. Returns null if the player doesnt exist.
* @param uid User id of the player
* @return
*/
public synchronized Player getPlayerByAccount(Account account) {
public synchronized Player loadPlayer(Account account) {
// Get player from cache
Player player = this.cachedPlayersByAccount.get(account.getUid());
@@ -108,12 +126,17 @@ public class PlayerModule extends GameContextModule {
}
/**
* Returns a list of recent players that have logged on (for followers)
* Returns a list of recent players that have logged on
* @param player Player that requested this
*/
public synchronized List<Player> getRandomPlayerList(Player player) {
List<Player> list = getCachedPlayers().values().stream().filter(p -> p != player).collect(Collectors.toList());
List<Player> list = getCachedPlayers().values().stream()
.filter(p -> p != player)
.limit(10)
.collect(Collectors.toList());
Collections.shuffle(list);
return list.stream().limit(15).toList();
return list.stream().toList();
}
}

View File

@@ -121,7 +121,7 @@ public class GameSession {
// Note: We should cache players in case multiple sessions try to login to the same player at the time
// Get player by account
var player = Nebula.getGameContext().getPlayerModule().getPlayerByAccount(account);
var player = Nebula.getGameContext().getPlayerModule().loadPlayer(account);
// Skip intro
if (player == null && Nebula.getConfig().getServerOptions().skipIntro) {

View File

@@ -0,0 +1,33 @@
package emu.nebula.server.handlers;
import emu.nebula.net.NetHandler;
import emu.nebula.net.NetMsgId;
import emu.nebula.proto.FriendAddAgree.FriendAddAgreeReq;
import emu.nebula.proto.FriendAddAgree.FriendAddAgreeResp;
import emu.nebula.net.HandlerId;
import emu.nebula.net.GameSession;
@HandlerId(NetMsgId.friend_add_agree_req)
public class HandlerFriendAddAgreeReq extends NetHandler {
@Override
public byte[] handle(GameSession session, byte[] message) throws Exception {
// Parse request
var req = FriendAddAgreeReq.parseFrom(message);
// Handle friend request
var target = session.getPlayer().getFriendList().handleFriendRequest((int) req.getUId(), true);
if (target == null) {
return session.encodeMsg(NetMsgId.friend_add_agree_failed_ack);
}
// Build response
var rsp = FriendAddAgreeResp.newInstance()
.setFriend(target.getFriendProto());
// Encode and send
return session.encodeMsg(NetMsgId.friend_add_agree_succeed_ack, rsp);
}
}

View File

@@ -0,0 +1,25 @@
package emu.nebula.server.handlers;
import emu.nebula.net.NetHandler;
import emu.nebula.net.NetMsgId;
import emu.nebula.proto.FriendAdd.FriendAddReq;
import emu.nebula.net.HandlerId;
import emu.nebula.net.GameSession;
@HandlerId(NetMsgId.friend_add_req)
public class HandlerFriendAddReq extends NetHandler {
@Override
public byte[] handle(GameSession session, byte[] message) throws Exception {
// Parse request
var req = FriendAddReq.parseFrom(message);
int uid = (int) req.getUId();
// Send friend request
boolean success = session.getPlayer().getFriendList().sendFriendRequest(uid);
// Encode and send
return session.encodeMsg(success ? NetMsgId.friend_add_succeed_ack : NetMsgId.friend_add_failed_ack);
}
}

View File

@@ -0,0 +1,35 @@
package emu.nebula.server.handlers;
import emu.nebula.net.NetHandler;
import emu.nebula.net.NetMsgId;
import emu.nebula.proto.FriendAllAgree.FriendAllAgreeResp;
import emu.nebula.net.HandlerId;
import emu.nebula.net.GameSession;
@HandlerId(NetMsgId.friend_all_agree_req)
public class HandlerFriendAllAgreeReq extends NetHandler {
@Override
public byte[] handle(GameSession session, byte[] message) throws Exception {
// Agree to all friend requests
var results = session.getPlayer().getFriendList().acceptAll();
// Scuffed way of getting friend data
var proto = session.getPlayer().getFriendList().getCachedProto();
// Build response
var rsp = FriendAllAgreeResp.newInstance();
for (var f : results) {
rsp.addFriends(f.getFriendProto());
}
for (var i : proto.getInvites()) {
rsp.addInvites(i);
}
// Encode and send
return session.encodeMsg(NetMsgId.friend_all_agree_succeed_ack, rsp);
}
}

View File

@@ -0,0 +1,24 @@
package emu.nebula.server.handlers;
import emu.nebula.net.NetHandler;
import emu.nebula.net.NetMsgId;
import emu.nebula.proto.FriendDelete.FriendDeleteReq;
import emu.nebula.net.HandlerId;
import emu.nebula.net.GameSession;
@HandlerId(NetMsgId.friend_delete_req)
public class HandlerFriendDeleteReq extends NetHandler {
@Override
public byte[] handle(GameSession session, byte[] message) throws Exception {
// Parse request
var req = FriendDeleteReq.parseFrom(message);
// Delete friend
boolean success = session.getPlayer().getFriendList().deleteFriend((int) req.getUId());
// Encode and send
return session.encodeMsg(success ? NetMsgId.friend_delete_succeed_ack : NetMsgId.friend_delete_failed_ack);
}
}

View File

@@ -0,0 +1,26 @@
package emu.nebula.server.handlers;
import emu.nebula.net.NetHandler;
import emu.nebula.net.NetMsgId;
import emu.nebula.proto.FriendInvitesDelete.FriendInvitesDeleteReq;
import emu.nebula.net.HandlerId;
import emu.nebula.net.GameSession;
@HandlerId(NetMsgId.friend_invites_delete_req)
public class HandlerFriendInvitesDeleteReq extends NetHandler {
@Override
public byte[] handle(GameSession session, byte[] message) throws Exception {
// Parse request
var req = FriendInvitesDeleteReq.parseFrom(message);
// Delete all invites
for (long uid : req.getMutableUIds()) {
session.getPlayer().getFriendList().handleFriendRequest((int) uid, false);
}
// Encode and send
return session.encodeMsg(NetMsgId.friend_invites_delete_succeed_ack);
}
}

View File

@@ -2,7 +2,6 @@ package emu.nebula.server.handlers;
import emu.nebula.net.NetHandler;
import emu.nebula.net.NetMsgId;
import emu.nebula.proto.FriendListGet.FriendListGetResp;
import emu.nebula.net.HandlerId;
import emu.nebula.net.GameSession;
@@ -11,8 +10,10 @@ public class HandlerFriendListGetReq extends NetHandler {
@Override
public byte[] handle(GameSession session, byte[] message) throws Exception {
var rsp = FriendListGetResp.newInstance();
// Build response
var rsp = session.getPlayer().getFriendList().toProto();
// Encode and send
return session.encodeMsg(NetMsgId.friend_list_get_succeed_ack, rsp);
}

View File

@@ -0,0 +1,29 @@
package emu.nebula.server.handlers;
import emu.nebula.net.NetHandler;
import emu.nebula.net.NetMsgId;
import emu.nebula.proto.FriendRecommendationGet.FriendRecommendationGetResp;
import emu.nebula.net.HandlerId;
import emu.nebula.Nebula;
import emu.nebula.net.GameSession;
@HandlerId(NetMsgId.friend_recommendation_get_req)
public class HandlerFriendRecommendationGetReq extends NetHandler {
@Override
public byte[] handle(GameSession session, byte[] message) throws Exception {
// Build response
var rsp = FriendRecommendationGetResp.newInstance();
// Get players
var players = Nebula.getGameContext().getPlayerModule().getRandomPlayerList(session.getPlayer());
for (var player : players) {
rsp.addFriends(player.getFriendProto());
}
// Encode and send
return session.encodeMsg(NetMsgId.friend_recommendation_get_succeed_ack, rsp);
}
}

View File

@@ -0,0 +1,35 @@
package emu.nebula.server.handlers;
import emu.nebula.net.NetHandler;
import emu.nebula.net.NetMsgId;
import emu.nebula.proto.FriendUidSearch.FriendUIdSearchReq;
import emu.nebula.proto.FriendUidSearch.FriendUIdSearchResp;
import emu.nebula.net.HandlerId;
import emu.nebula.Nebula;
import emu.nebula.net.GameSession;
@HandlerId(NetMsgId.friend_uid_search_req)
public class HandlerFriendUidSearchReq extends NetHandler {
@Override
public byte[] handle(GameSession session, byte[] message) throws Exception {
// Parse request
var req = FriendUIdSearchReq.parseFrom(message);
int uid = (int) req.getId();
// Get target player
var target = Nebula.getGameContext().getPlayerModule().getPlayer(uid);
if (target == null) {
return session.encodeMsg(NetMsgId.friend_uid_search_failed_ack);
}
// Build response
var rsp = FriendUIdSearchResp.newInstance()
.setFriend(target.getFriendProto());
// Encode and send
return session.encodeMsg(NetMsgId.friend_uid_search_succeed_ack, rsp);
}
}