Refactor battle pass and some gacha stuff

This commit is contained in:
Melledy committed 2026-10-22 22:14:17 -07:00
1 parent c9e62500ad
commit a928bb40f0
10 files changed
+160 -133

No files matched your search

@@ -51,6 +51,8 @@ public class GameConstants {
public static final int MAX_PRESETS = 50;
public static final int MAX_VITA_PROJECTS = 5;
public static final int MAX_GACHA_HISTORY_SAVE_RETRIES = 2;
public static final int MAX_FRIENDSHIPS = 50;
public static final int MAX_PENDING_FRIENDSHIPS = 30;
@@ -2,6 +2,7 @@ package emu.nebula.game.battlepass;
import dev.morphia.annotations.Entity;
import dev.morphia.annotations.Id;
import emu.nebula.GameConstants;
import emu.nebula.Nebula;
import emu.nebula.data.GameData;
@@ -16,6 +17,7 @@ import emu.nebula.game.quest.QuestType;
import emu.nebula.net.NetMsgId;
import emu.nebula.proto.BattlePassInfoOuterClass.BattlePassInfo;
import emu.nebula.util.Bitset;
import lombok.Getter;
import lombok.Setter;
@@ -134,7 +136,7 @@ public class BattlePass implements GameDatabaseObject {
* Check claimable tasks to show battle pass red dot.
*/
public synchronized boolean hasClaimableQuest() {
if (!this.getPlayer().isBattlePassUnlocked()) {
if (!this.getManager().isUnlocked()) {
return false;
}
@@ -157,7 +159,7 @@ public class BattlePass implements GameDatabaseObject {
* reward lane item for the player's current level/mode.
*/
public synchronized boolean hasClaimableReward() {
if (!this.getPlayer().isBattlePassUnlocked()) {
if (!this.getManager().isUnlocked()) {
return false;
}
@@ -238,7 +240,7 @@ public class BattlePass implements GameDatabaseObject {
* Update this quest on the player client
*/
private void syncQuest(GameQuest quest) {
if (!getPlayer().hasSession() || !getPlayer().isBattlePassUnlocked()) {
if (!getPlayer().hasSession() || !getManager().isUnlocked()) {
return;
}
@@ -399,13 +401,72 @@ public class BattlePass implements GameDatabaseObject {
// Add rewards
return getPlayer().getInventory().addItems(rewards);
}
public PlayerChangeInfo buyLevels(int levels) {
// Sanity check
if (levels < 1) {
return null;
}
// Setup
int costItemId = 2; // Gems
int currentLevel = this.getLevel();
int totalGemCost = 0;
int totalExp = 0;
int affordableLevels = 0;
var inventory = getPlayer().getInventory();
// Calculate total cost and exp for the levels we can afford
for (int i = 1; i <= levels; i++) {
// Get level data
var data = GameData.getBattlePassLevelDataTable().get(currentLevel + i);
// Stop if max level reached
if (data == null) {
break;
}
// Stop if not enough gems for this level
int cost = data.getQty();
if (inventory.getResourceCount(costItemId) < totalGemCost + cost) {
break;
}
totalGemCost += cost;
totalExp += data.getExp();
affordableLevels++;
}
// Make sure we can buy at least one level
if (affordableLevels == 0) {
return null;
}
// Prepare change info
var change = new PlayerChangeInfo();
// Deduct gems
if (totalGemCost > 0) {
change.add(inventory.addItem(costItemId, -totalGemCost));
}
// Add all exp at once
this.addExp(totalExp);
// Save to database
this.save();
// Success
return change;
}
// Proto
public BattlePassInfo toProto() {
// Return a locked/empty snapshot until the battle pass feature is unlocked,
// so the client cannot derive local quest or reward red dots from battle pass data.
if (!this.getPlayer().isBattlePassUnlocked()) {
if (!this.getManager().isUnlocked()) {
return BattlePassInfo.newInstance()
.setId(0)
.setLevel(0)
@@ -1,8 +1,12 @@
package emu.nebula.game.battlepass;
import emu.nebula.GameConstants;
import emu.nebula.Nebula;
import emu.nebula.game.player.Player;
import emu.nebula.game.player.PlayerManager;
import emu.nebula.net.NetMsgId;
import emu.nebula.proto.Public.BattlePassState;
import lombok.Getter;
@Getter
@@ -13,6 +17,51 @@ public class BattlePassManager extends PlayerManager {
super(player);
}
public boolean isUnlocked() {
return this.getPlayer().getLevel() >= GameConstants.BATTLE_PASS_UNLOCK_LEVEL;
}
/**
* Updates the battle pass state on the client
*/
public void updateState() {
this.getPlayer().addNextPackage(
NetMsgId.battle_pass_state_notify,
this.getStateProto()
);
}
/**
* Pushes a full battle-pass snapshot for clients that only refresh their
* local battle-pass cache/red dots from the page-info payload.
*/
public void queueBattlePassInfoNotify() {
this.getPlayer().addNextPackage(
NetMsgId.battle_pass_info_succeed_ack,
this.getBattlePass().toProto()
);
}
/**
* Battle-pass unlock is a special case: clients need the full page snapshot
* to initialize local battle-pass cache and refresh the main-entry red dot.
*/
public void queueUnlockNotify(int oldLevel) {
if (oldLevel < GameConstants.BATTLE_PASS_UNLOCK_LEVEL && this.isUnlocked()) {
this.queueBattlePassInfoNotify();
}
}
// Proto
public BattlePassState getStateProto() {
int state = this.isUnlocked()
? this.getBattlePass().getClientState()
: 0;
return BattlePassState.newInstance().setState(state);
}
// Database
public void loadFromDatabase() {
@@ -2,6 +2,7 @@ package emu.nebula.game.gacha;
import dev.morphia.annotations.Entity;
import dev.morphia.annotations.Id;
import emu.nebula.GameConstants;
import emu.nebula.Nebula;
import emu.nebula.data.resources.GachaDef;
import emu.nebula.data.resources.GachaNewbieDef;
@@ -19,19 +20,20 @@ import java.util.Set;
@Getter
@Entity(value = "gacha", useDiscriminator = false)
public class GachaManager extends PlayerManager implements GameDatabaseObject {
private static final String PATH_BANNERS = "banners.";
private static final String PATH_PITY_STATES = "pityStates.";
private static final String PATH_NEWBIE_STATES = "newbieStates.";
private static final int HISTORY_SAVE_RETRIES = 2;
@Id
private int uid;
private Map<Integer, GachaBannerInfo> banners = new HashMap<>();
private Map<Integer, GachaPityState> pityStates = new HashMap<>();
private Map<Integer, NewbieGachaState> newbieStates = new HashMap<>();
// Not saved to the database
private transient Set<Integer> lockedNewbieObtainIds = new HashSet<>();
private static final String PATH_BANNERS = "banners.";
private static final String PATH_PITY_STATES = "pityStates.";
private static final String PATH_NEWBIE_STATES = "newbieStates.";
@Deprecated // Morphia only
public GachaManager() {
@@ -91,7 +93,7 @@ public class GachaManager extends PlayerManager implements GameDatabaseObject {
var log = new GachaHistoryLog(this.getPlayerUid(), storageId, gachaId, results);
Exception lastError = null;
for (int attempt = 1; attempt <= HISTORY_SAVE_RETRIES + 1; attempt++) {
for (int attempt = 1; attempt <= GameConstants.MAX_GACHA_HISTORY_SAVE_RETRIES + 1; attempt++) {
try {
Nebula.getGameDatabase().save(log);
return;
@@ -4,6 +4,7 @@ import dev.morphia.annotations.AlsoLoad;
import dev.morphia.annotations.Entity;
import dev.morphia.annotations.Id;
import dev.morphia.annotations.Indexed;
import emu.nebula.GameConstants;
import emu.nebula.Nebula;
import emu.nebula.data.GameData;
@@ -40,12 +41,13 @@ import emu.nebula.proto.Notify.SigninRewardUpdate;
import emu.nebula.proto.PlayerData.DictionaryEntry;
import emu.nebula.proto.PlayerData.DictionaryTab;
import emu.nebula.proto.PlayerData.PlayerInfo;
import emu.nebula.proto.Public;
import emu.nebula.proto.Public.*;
import emu.nebula.util.ResetCycle;
import emu.nebula.util.Utils;
import emu.nebula.util.ints.String2IntMap;
import lombok.Getter;
import us.hebi.quickbuf.ProtoMessage;
import us.hebi.quickbuf.RepeatedInt;
@@ -231,7 +233,7 @@ public class Player implements GameDatabaseObject {
this.trigger(AchievementCondition.WorldClassSpecific, this.getLevel());
if (oldLevel != this.level) {
this.queueBattlePassUnlockNotify(oldLevel);
this.getBattlePassManager().queueUnlockNotify(oldLevel);
}
}
@@ -556,7 +558,8 @@ public class Player implements GameDatabaseObject {
// Trigger achievement
this.trigger(AchievementCondition.WorldClassSpecific, this.getLevel());
this.queueBattlePassUnlockNotify(oldLevel);
// Send battle pass unlock notify packet
this.getBattlePassManager().queueUnlockNotify(oldLevel);
}
// Calculate changes
@@ -759,6 +762,10 @@ public class Player implements GameDatabaseObject {
}
}
private long getLastResetDayTimeSeconds() {
return Utils.getResetTimeSecondsByEpochDay(this.getLastEpochDay());
}
// Trigger quests + achievements
public void trigger(int condition, int progress, int param1, int param2) {
@@ -879,6 +886,8 @@ public class Player implements GameDatabaseObject {
Nebula.getGameDatabase().update(this, this.getUid(), "lastLogin", this.getLastLogin());
}
// Mall
/**
* Returns whether the player currently has at least one purchasable
* free mall package (`CurrencyType = 3`) for the mall entrance red dot.
@@ -907,42 +916,8 @@ public class Player implements GameDatabaseObject {
MallPackageState.newInstance().setNew(this.hasAvailableFreeMallPackage())
);
}
public void queueBattlePassStateNotify() {
this.addNextPackage(
NetMsgId.battle_pass_state_notify,
this.buildBattlePassStateProto()
);
}
/**
* Pushes a full battle-pass snapshot for clients that only refresh their
* local battle-pass cache/red dots from the page-info payload.
*/
public void queueBattlePassInfoNotify() {
this.addNextPackage(
NetMsgId.battle_pass_info_succeed_ack,
this.getBattlePassManager().getBattlePass().toProto()
);
}
/**
* Battle-pass unlock is a special case: clients need the full page snapshot
* to initialize local battle-pass cache and refresh the main-entry red dot.
*/
private void queueBattlePassUnlockNotify(int oldLevel) {
if (oldLevel < GameConstants.BATTLE_PASS_UNLOCK_LEVEL && this.isBattlePassUnlocked()) {
this.queueBattlePassInfoNotify();
}
}
private Public.BattlePassState buildBattlePassStateProto() {
int state = this.isBattlePassUnlocked()
? this.getBattlePassManager().getBattlePass().getClientState()
: 0;
return Public.BattlePassState.newInstance().setState(state);
}
// Monthly Card
/**
* Returns the number of future game days still covered after the current game day.
@@ -1089,10 +1064,6 @@ public class Player implements GameDatabaseObject {
this.grantMonthlyCardReward(entry.getKey(), notifyOnly);
}
}
private long getLastResetDayTimeSeconds() {
return Utils.getResetTimeSecondsByEpochDay(this.getLastEpochDay());
}
// Next packages
@@ -1263,7 +1234,7 @@ public class Player implements GameDatabaseObject {
// Keep BattlePass state container present for client-side login cache flow.
// Some clients assume State.BattlePass exists during player_data bootstrap.
state.setBattlePass(this.buildBattlePassStateProto());
state.setBattlePass(this.getBattlePassManager().getStateProto());
state.getMutableAchievement()
.setNew(this.getAchievementManager().hasNewAchievements());
@@ -1415,8 +1386,4 @@ public class Player implements GameDatabaseObject {
return proto;
}
public boolean isBattlePassUnlocked() {
return this.getLevel() >= GameConstants.BATTLE_PASS_UNLOCK_LEVEL;
}
}
@@ -1,13 +1,10 @@
package emu.nebula.server.handlers;
import emu.nebula.data.GameData;
import emu.nebula.game.player.PlayerChangeInfo;
import emu.nebula.net.GameSession;
import emu.nebula.net.HandlerId;
import emu.nebula.net.NetHandler;
import emu.nebula.net.NetMsgId;
import emu.nebula.proto.BattlePassLevelBuy.BattlePassLevelBuyResp;
import emu.nebula.proto.Public.ChangeInfo;
import emu.nebula.proto.Public.UI32;
@HandlerId(NetMsgId.battle_pass_level_buy_req)
@@ -15,85 +12,31 @@ public class HandlerBattlePassLevelBuyReq extends NetHandler {
@Override
public byte[] handle(GameSession session, byte[] message) throws Exception {
if (!session.getPlayer().isBattlePassUnlocked()) {
// Sanity check to make sure battlepass is unlocked
if (!session.getPlayer().getBattlePassManager().isUnlocked()) {
return session.encodeMsg(NetMsgId.battle_pass_level_buy_failed_ack);
}
// Parse request using UI32 (standard uint32 protobuf type)
// Parse request
var req = UI32.parseFrom(message);
int levelsToBuy = req.getValue();
if (levelsToBuy < 1) {
levelsToBuy = 1;
}
// Get battle pass
// Get battlepass
var battlePass = session.getPlayer().getBattlePassManager().getBattlePass();
// Calculate total cost and total exp
int currentLevel = battlePass.getLevel();
int totalGemCost = 0;
int totalExpToAdd = 0;
int affordableLevels = 0;
int costItemId = 2; // Gems
// Buy levels
var change = battlePass.buyLevels(req.getValue());
for (int i = 1; i <= levelsToBuy; i++) {
int targetLevel = currentLevel + i;
var levelData = GameData.getBattlePassLevelDataTable().get(targetLevel);
if (levelData == null) {
// Max level reached
break;
}
int cost = levelData.getQty();
int currentGems = session.getPlayer().getInventory().getResourceCount(costItemId);
if (currentGems < totalGemCost + cost) {
// Not enough gems for all levels
break;
}
totalGemCost += cost;
totalExpToAdd += levelData.getExp();
affordableLevels++;
}
if (affordableLevels == 0) {
// Check
if (change == null) {
return session.encodeMsg(NetMsgId.battle_pass_level_buy_failed_ack);
}
// Prepare change info
var change = new PlayerChangeInfo();
// Deduct gems (total cost)
if (totalGemCost > 0) {
var gemCost = session.getPlayer().getInventory().addItem(costItemId, -totalGemCost);
if (gemCost != null) {
change.add(gemCost);
}
}
// Add all exp at once (preserves current exp)
battlePass.addExp(totalExpToAdd);
// Save to database
battlePass.save();
// Build response
var rsp = BattlePassLevelBuyResp.newInstance()
.setLevel(battlePass.getLevel());
// Add change info (gem cost)
if (!change.isEmpty()) {
var changeProto = ChangeInfo.newInstance();
for (var any : change.getList()) {
changeProto.addProps(any);
}
rsp.setChange(changeProto);
}
.setLevel(battlePass.getLevel())
.setChange(change.toProto());
session.getPlayer().queueBattlePassStateNotify();
session.getPlayer().getBattlePassManager().updateState();
// Encode and send
return session.encodeMsg(NetMsgId.battle_pass_level_buy_succeed_ack, rsp);
@@ -1,6 +1,7 @@
package emu.nebula.server.handlers;
import emu.nebula.Nebula;
import emu.nebula.game.mall.MallOrderCollectResult;
import emu.nebula.net.NetHandler;
import emu.nebula.net.NetMsgId;
import emu.nebula.proto.BattlePassOrderCollect.BattlePassOrderCollectResp;
@@ -13,7 +14,8 @@ public class HandlerBattlePassOrderCollectReq extends NetHandler {
@Override
public byte[] handle(GameSession session, byte[] message) throws Exception {
if (!session.getPlayer().isBattlePassUnlocked()) {
// Sanity check to make sure battlepass is unlocked
if (!session.getPlayer().getBattlePassManager().isUnlocked()) {
return session.encodeMsg(NetMsgId.battle_pass_order_collect_failed_ack);
}
@@ -26,7 +28,7 @@ public class HandlerBattlePassOrderCollectReq extends NetHandler {
if (!battlePass.isPremium()) {
return session.encodeMsg(NetMsgId.battle_pass_order_collect_failed_ack);
}
collectResult = emu.nebula.game.mall.MallOrderCollectResult.empty();
collectResult = MallOrderCollectResult.empty();
}
// Build collect response
@@ -15,9 +15,10 @@ public class HandlerBattlePassOrderReq extends NetHandler {
public byte[] handle(GameSession session, byte[] message) throws Exception {
// Parse request
var req = BattlePassOrderReq.parseFrom(message);
// Validate mode (1 = Premium, 2 = Luxury)
int requestedMode = req.getMode();
if (!session.getPlayer().isBattlePassUnlocked() || requestedMode < 1 || requestedMode > 2) {
if (!session.getPlayer().getBattlePassManager().isUnlocked() || requestedMode < 1 || requestedMode > 2) {
return session.encodeMsg(NetMsgId.battle_pass_order_failed_ack);
}
@@ -12,7 +12,7 @@ public class HandlerBattlePassQuestRewardReceiveReq extends NetHandler {
@Override
public byte[] handle(GameSession session, byte[] message) throws Exception {
if (!session.getPlayer().isBattlePassUnlocked()) {
if (!session.getPlayer().getBattlePassManager().isUnlocked()) {
return session.encodeMsg(NetMsgId.battle_pass_quest_reward_receive_failed_ack);
}
@@ -32,7 +32,7 @@ public class HandlerBattlePassQuestRewardReceiveReq extends NetHandler {
.setExp(battlePass.getExp())
.setExpThisWeek(battlePass.getExpWeek());
session.getPlayer().queueBattlePassStateNotify();
session.getPlayer().getBattlePassManager().updateState();
// Encode and send
return session.encodeMsg(NetMsgId.battle_pass_quest_reward_receive_succeed_ack, rsp);
@@ -13,7 +13,7 @@ public class HandlerBattlePassRewardReceiveReq extends NetHandler {
@Override
public byte[] handle(GameSession session, byte[] message) throws Exception {
if (!session.getPlayer().isBattlePassUnlocked()) {
if (!session.getPlayer().getBattlePassManager().isUnlocked()) {
return session.encodeMsg(NetMsgId.battle_pass_reward_receive_failed_ack);
}
@@ -44,7 +44,7 @@ public class HandlerBattlePassRewardReceiveReq extends NetHandler {
.setPremiumReward(battlePass.getPremiumReward().toByteArray())
.setChange(change.toProto());
session.getPlayer().queueBattlePassStateNotify();
session.getPlayer().getBattlePassManager().updateState();
// Encode and send
return session.encodeMsg(NetMsgId.battle_pass_reward_receive_succeed_ack, rsp);