Fix mall, shop, mall/shop refresh logic and battle pass flows

This commit is contained in:
Yostarcc
2026-05-25 18:43:02 -07:00
committed by Melledy
parent c1c09c6594
commit 57c05c8e32
45 changed files with 2171 additions and 232 deletions
+15 -21
View File
@@ -1,21 +1,20 @@
package emu.nebula.game;
import java.time.Instant;
import java.time.LocalDate;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import emu.nebula.GameConstants;
import emu.nebula.Nebula;
import emu.nebula.game.activity.ActivityModule;
import emu.nebula.game.ban.BanModule;
import emu.nebula.game.gacha.GachaModule;
import emu.nebula.game.jointdrill.JointDrillModule;
import emu.nebula.game.mall.PayModule;
import emu.nebula.game.player.PlayerModule;
import emu.nebula.game.scoreboss.ScoreBossModule;
import emu.nebula.game.tutorial.TutorialModule;
import emu.nebula.net.GameSession;
import emu.nebula.util.ResetCycle;
import emu.nebula.util.Utils;
import it.unimi.dsi.fastutil.objects.Object2ObjectMap;
import it.unimi.dsi.fastutil.objects.Object2ObjectOpenHashMap;
@@ -34,14 +33,15 @@ public class GameContext implements Runnable {
private final ScoreBossModule scoreBossModule;
private final JointDrillModule jointDrillModule;
private final BanModule banModule;
private final PayModule payModule;
// Game loop
private final ScheduledExecutorService scheduler;
// Daily
private long epochDays;
private int epochWeeks;
private int epochMonths;
// Reset-period indexes aligned to the configured daily reset boundary.
private long resetDays;
private int resetWeeks;
private int resetMonths;
public GameContext() {
// Create session map
@@ -55,6 +55,7 @@ public class GameContext implements Runnable {
this.scoreBossModule = new ScoreBossModule(this);
this.jointDrillModule = new JointDrillModule(this);
this.banModule = new BanModule(this);
this.payModule = new PayModule(this);
// Run game loop
this.scheduler = Executors.newScheduledThreadPool(1);
@@ -76,7 +77,7 @@ public class GameContext implements Runnable {
}
// Generate token
String token = null;
String token;
do {
token = session.generateToken();
@@ -107,20 +108,14 @@ public class GameContext implements Runnable {
@Override
public void run() {
// Check daily - Update epoch days
long offset = Nebula.getConfig().getServerOptions().getDailyResetHour() * -3600;
var instant = Instant.now().plusSeconds(offset);
var date = LocalDate.ofInstant(instant, GameConstants.UTC_ZONE);
// Update epoch days
long lastEpochDays = this.epochDays;
this.epochDays = date.toEpochDay();
long lastResetDays = this.resetDays;
this.resetDays = Utils.getResetEpochDay();
// Check if the day was changed
if (this.epochDays > lastEpochDays) {
if (this.resetDays > lastResetDays) {
// Update epoch weeks/months
this.epochWeeks = Utils.getWeeks(this.epochDays);
this.epochMonths = Utils.getMonths(this.epochDays);
this.resetWeeks = Utils.getResetPeriodIndex(ResetCycle.WEEKLY, Nebula.getCurrentServerTime());
this.resetMonths = Utils.getResetPeriodIndex(ResetCycle.MONTHLY, Nebula.getCurrentServerTime());
// Update score boss season
this.getScoreBossModule().updateSeason();
@@ -145,8 +140,7 @@ public class GameContext implements Runnable {
if (player == null) {
continue;
}
//
player.checkResetDailies();
}
}
@@ -148,11 +148,12 @@ public enum AchievementCondition {
}
}
private AchievementCondition(int value) {
AchievementCondition(int value) {
this.value = value;
}
public static AchievementCondition getByValue(int value) {
return map.get(value);
}
}
@@ -1,15 +1,11 @@
package emu.nebula.game.battlepass;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Map;
import dev.morphia.annotations.Entity;
import dev.morphia.annotations.Id;
import emu.nebula.GameConstants;
import emu.nebula.Nebula;
import emu.nebula.data.GameData;
import emu.nebula.data.resources.BattlePassDef;
import emu.nebula.data.resources.BattlePassRewardDef;
import emu.nebula.database.GameDatabaseObject;
import emu.nebula.game.inventory.ItemParamMap;
@@ -20,10 +16,15 @@ 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;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Map;
@Getter
@Setter
@Entity(value = "battlepass", useDiscriminator = false)
public class BattlePass implements GameDatabaseObject {
@Id
@@ -49,7 +50,7 @@ public class BattlePass implements GameDatabaseObject {
public BattlePass(BattlePassManager manager) {
this.uid = manager.getPlayerUid();
this.manager = manager;
this.battlePassId = GameConstants.BATTLE_PASS_ID;
this.battlePassId = getActiveBattlePassId();
this.basicReward = new Bitset();
this.premiumReward = new Bitset();
@@ -63,8 +64,22 @@ public class BattlePass implements GameDatabaseObject {
this.save();
}
public void setManager(BattlePassManager manager) {
this.manager = manager;
private static int getActiveBattlePassId() {
long currentTimeSec = Nebula.getCurrentServerTime();
for (BattlePassDef data : GameData.getBattlePassDataTable()) {
if (data == null) {
continue;
}
long start = data.getStartTimeTimestamp();
long end = data.getEndTimeTimestamp();
if (currentTimeSec >= start && currentTimeSec <= end) {
return data.getId();
}
}
return GameConstants.BATTLE_PASS_ID;
}
public Player getPlayer() {
@@ -116,30 +131,63 @@ public class BattlePass implements GameDatabaseObject {
}
/**
* Returns true if any rewards or quests are claimable
* Check claimable tasks to show battle pass red dot.
*/
public synchronized boolean hasNew() {
// Check if any quests are complete but unclaimed
public synchronized boolean hasClaimableQuest() {
if (!this.getPlayer().isBattlePassUnlocked()) {
return false;
}
var nextLevelData = GameData.getBattlePassLevelDataTable().get(this.getLevel() + 1);
if (nextLevelData == null) {
return false;
}
for (var quest : getQuests().values()) {
if (quest.isComplete() && !quest.isClaimed()) {
return true;
}
}
// Check if we have any pending rewards
return false;
}
/**
* Returns whether the battle pass currently has at least one claimable
* reward lane item for the player's current level/mode.
*/
public synchronized boolean hasClaimableReward() {
if (!this.getPlayer().isBattlePassUnlocked()) {
return false;
}
for (int i = 1; i <= this.getLevel(); i++) {
if (!this.getBasicReward().isSet(i)) {
return true;
}
if (this.isPremium() && !this.getPremiumReward().isSet(i)) {
return true;
}
}
// No claimable things
return false;
}
/**
* Encodes the client battle-pass state red dot contract:
* 0 = none, 1 = quest only, 2 = reward only, 3 = both.
*/
public synchronized int getClientState() {
int state = 0;
if (this.hasClaimableQuest()) {
state |= 1;
}
if (this.hasClaimableReward()) {
state |= 2;
}
return state;
}
public synchronized void resetDailyQuests(boolean resetWeekly) {
// Reset daily quests
@@ -190,7 +238,7 @@ public class BattlePass implements GameDatabaseObject {
* Update this quest on the player client
*/
private void syncQuest(GameQuest quest) {
if (!getPlayer().hasSession()) {
if (!getPlayer().hasSession() || !getPlayer().isBattlePassUnlocked()) {
return;
}
@@ -269,7 +317,7 @@ public class BattlePass implements GameDatabaseObject {
public PlayerChangeInfo receiveReward(boolean premium, int levelId) {
// Get bitset
Bitset rewards = null;
Bitset rewards;
if (premium) {
rewards = this.getPremiumReward();
@@ -296,7 +344,13 @@ public class BattlePass implements GameDatabaseObject {
// Add items
if (premium) {
return getPlayer().getInventory().addItems(data.getPremiumRewards());
var premiumRewards = data.getPremiumRewards().clone();
if (this.getMode() >= 2 && data.hasLuxuryRewards()) {
premiumRewards.add(data.getLuxuryRewards());
}
return getPlayer().getInventory().addItems(premiumRewards);
} else {
return getPlayer().getInventory().addItems(data.getBasicRewards());
}
@@ -308,38 +362,29 @@ public class BattlePass implements GameDatabaseObject {
// Get unclaimed rewards
for (int i = 1; i <= this.getLevel(); i++) {
// Cache reward data
BattlePassRewardDef data = null;
// Basic reward
if (!this.getBasicReward().isSet(i)) {
// Set flag
this.getBasicReward().setBit(i);
// Get reward data if we havent already
if (data == null) {
data = this.getRewardData(i);
}
// Add basic rewards
if (data != null) {
rewards.add(data.getBasicRewards());
}
boolean claimBasic = !this.getBasicReward().isSet(i);
boolean claimPremium = this.isPremium() && !this.getPremiumReward().isSet(i);
if (!claimBasic && !claimPremium) {
continue;
}
// Premium reward
if (this.isPremium() && !this.getPremiumReward().isSet(i)) {
// Set flag
BattlePassRewardDef data = this.getRewardData(i);
if (data == null) {
continue;
}
if (claimBasic) {
this.getBasicReward().setBit(i);
rewards.add(data.getBasicRewards());
}
if (claimPremium) {
this.getPremiumReward().setBit(i);
// Get reward data if we havent already
if (data == null) {
data = this.getRewardData(i);
}
// Add basic rewards
if (data != null) {
rewards.add(data.getPremiumRewards());
rewards.add(data.getPremiumRewards());
if (this.getMode() >= 2 && data.hasLuxuryRewards()) {
rewards.add(data.getLuxuryRewards());
}
}
}
@@ -358,6 +403,20 @@ public class BattlePass implements GameDatabaseObject {
// 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()) {
return BattlePassInfo.newInstance()
.setId(0)
.setLevel(0)
.setMode(0)
.setExp(0)
.setExpThisWeek(0)
.setDeadline(0L)
.setBasicReward()
.setPremiumReward();
}
var proto = BattlePassInfo.newInstance()
.setId(this.getBattlePassId())
.setLevel(this.getLevel())
@@ -367,10 +426,10 @@ public class BattlePass implements GameDatabaseObject {
.setDeadline(Long.MAX_VALUE)
.setBasicReward(this.getBasicReward().toByteArray())
.setPremiumReward(this.getPremiumReward().toByteArray());
var daily = proto.getMutableDailyQuests();
var weekly = proto.getMutableWeeklyQuests();
for (var quest : this.getQuests().values()) {
if (quest.getType() == QuestType.BattlePassDaily) {
daily.addList(quest.toProto());
@@ -378,7 +437,8 @@ public class BattlePass implements GameDatabaseObject {
weekly.addList(quest.toProto());
}
}
return proto;
}
}
@@ -12,10 +12,6 @@ public class BattlePassManager extends PlayerManager {
public BattlePassManager(Player player) {
super(player);
}
public boolean hasNew() {
return this.getBattlePass().hasNew();
}
// Database
@@ -1,43 +1,37 @@
package emu.nebula.game.inventory;
import java.util.List;
import com.mongodb.client.model.Filters;
import dev.morphia.annotations.Entity;
import dev.morphia.annotations.Id;
import emu.nebula.GameConstants;
import emu.nebula.Nebula;
import emu.nebula.data.GameData;
import emu.nebula.data.resources.DropPkgDef;
import emu.nebula.data.resources.MallShopDef;
import emu.nebula.data.resources.ResidentGoodsDef;
import emu.nebula.data.resources.*;
import emu.nebula.database.GameDatabaseObject;
import emu.nebula.game.achievement.AchievementCondition;
import emu.nebula.game.player.Player;
import emu.nebula.game.player.PlayerChangeInfo;
import emu.nebula.game.player.PlayerManager;
import emu.nebula.game.quest.QuestCondition;
import emu.nebula.net.NetMsgId;
import emu.nebula.proto.Notify.Skin;
import emu.nebula.proto.Public.Honor;
import emu.nebula.proto.Public.Item;
import emu.nebula.proto.Public.Res;
import emu.nebula.proto.Public.Title;
import emu.nebula.proto.Public.UI32;
import emu.nebula.proto.Public.*;
import emu.nebula.util.Utils;
import emu.nebula.util.ints.String2IntMap;
import emu.nebula.game.achievement.AchievementCondition;
import emu.nebula.game.player.Player;
import emu.nebula.game.player.PlayerChangeInfo;
import it.unimi.dsi.fastutil.ints.IntCollection;
import it.unimi.dsi.fastutil.ints.IntOpenHashSet;
import it.unimi.dsi.fastutil.ints.IntSet;
import lombok.Getter;
import java.lang.String;
import java.util.List;
@Getter
@Entity(value = "inventory", useDiscriminator = false)
public class Inventory extends PlayerManager implements GameDatabaseObject {
@Id
private int uid;
// Items/resources
private ItemParamMap items;
private ItemParamMap resources;
@@ -51,8 +45,15 @@ public class Inventory extends PlayerManager implements GameDatabaseObject {
// Buy limit
private ItemParamMap shopBuyCount;
private String2IntMap mallBuyCount;
@Deprecated
private String2IntMap mallPackageBuyCount;
/**
* Tracks whether a MallGem pack has already consumed its maiden bonus.
* This is the only persisted state MallGem recharge still needs now that
* recharge packs no longer have any purchase-limit semantics.
*/
private String2IntMap gemMaidenClaimed;
private String2IntMap monthlyCardBuyCount;
public Inventory() {
// Morphia only
}
@@ -73,7 +74,10 @@ public class Inventory extends PlayerManager implements GameDatabaseObject {
this.shopBuyCount = new ItemParamMap();
this.mallBuyCount = new String2IntMap();
this.mallPackageBuyCount = new String2IntMap();
this.gemMaidenClaimed = new String2IntMap();
this.monthlyCardBuyCount = new String2IntMap();
// Add player heads
this.getHeadIcons().add(101);
this.getHeadIcons().add(102);
@@ -562,6 +566,29 @@ public class Inventory extends PlayerManager implements GameDatabaseObject {
change.add(proto);
}
}
case HeadItem -> {
// Cannot remove head icons
if (amount <= 0) {
break;
}
// Ensure the head icon exists in data and persist ownership.
var headData = GameData.getPlayerHeadDataTable().get(id);
if (headData == null) {
break;
}
// Persist ownership and expose it in ChangeInfo as a normal item reward,
// so regular reward popup can still show the head icon entry.
if (this.addHeadIcon(id)) {
// Client head-page state/red-dot updates are driven by proto.HeadIcon
// in ChangeInfo, not by a generic proto.Item entry.
var proto = HeadIcon.newInstance()
.setTid(id);
change.add(proto);
}
}
default -> {
// Not implemented
}
@@ -651,7 +678,7 @@ public class Inventory extends PlayerManager implements GameDatabaseObject {
}
/**
* Checks if the player has enough quanity of this item
* Checks if the player has enough quantity of this item
*/
public synchronized boolean hasItem(int id, int count) {
// Sanity check
@@ -726,7 +753,7 @@ public class Inventory extends PlayerManager implements GameDatabaseObject {
}
// Get materials
var materials = data.getMaterials().mulitply(num);
var materials = data.getMaterials().multiply(num);
// Verify that we have the materials
if (!this.hasItems(materials)) {
@@ -774,8 +801,7 @@ public class Inventory extends PlayerManager implements GameDatabaseObject {
public PlayerChangeInfo buyMallItem(MallShopDef data, int buyCount) {
// Check stock
int stock = data.getStock(this.getPlayer());
if (buyCount > stock) {
if (!data.canPurchase(this.getPlayer(), buyCount)) {
return null;
}
@@ -787,14 +813,14 @@ public class Inventory extends PlayerManager implements GameDatabaseObject {
}
// Update purchase limit
this.getMallBuyCount().addTo(data.getIdString(), buyCount);
int purchaseCount = this.getMallShopPurchaseCount(data.getIdString());
this.setMallCounterValue(this.getMallBuyCount(), data.getIdString(), purchaseCount + buyCount);
Nebula.getGameDatabase().update(
this,
getUid(),
"mallBuyCount." + data.getIdString(),
getMallBuyCount().get(data.getIdString())
);
this,
getUid(),
"mallBuyCount",
this.getMallBuyCount());
// Return
return change;
}
@@ -846,7 +872,7 @@ public class Inventory extends PlayerManager implements GameDatabaseObject {
this.removeItem(currencyId, cost, change);
// Add items
this.addItems(buyItems.mulitply(buyCount), change);
this.addItems(buyItems.multiply(buyCount), change);
// Success
return change.setSuccess(true);
@@ -879,7 +905,7 @@ public class Inventory extends PlayerManager implements GameDatabaseObject {
switch (data.getUseAction()) {
case 2 -> {
// Add items
this.addItems(data.getUseParams().mulitply(count), change);
this.addItems(data.getUseParams().multiply(count), change);
// Success
success = true;
@@ -912,18 +938,18 @@ public class Inventory extends PlayerManager implements GameDatabaseObject {
return change.setSuccess(true);
}
public PlayerChangeInfo convertGems(int amount) {
// Verify that we have the gems
if (!this.hasItem(GameConstants.PREM_GEM_ITEM_ID, amount)) {
public PlayerChangeInfo convertStellaniteLuminaToDust(int amount) {
// Verify that we have enough stellanite lumina in the combined paid/free pool.
if (!this.hasMallPackageCurrency(GameConstants.FREE_STELLANITE_LUMINA_ITEM_ID, amount)) {
return null;
}
// Create change info
var change = new PlayerChangeInfo();
// Convert gems
this.removeItem(GameConstants.PREM_GEM_ITEM_ID, amount, change);
this.addItem(GameConstants.GEM_ITEM_ID, amount, change);
// Convert stellanite lumina into stellanite dust.
this.consumeMallPackageCurrency(GameConstants.FREE_STELLANITE_LUMINA_ITEM_ID, amount, change);
this.addItem(GameConstants.STELLANITE_DUST_ITEM_ID, amount, change);
// Success
return change.setSuccess(true);
@@ -942,7 +968,207 @@ public class Inventory extends PlayerManager implements GameDatabaseObject {
Nebula.getGameDatabase().update(this, this.getUid(), "mallBuyCount", this.getMallBuyCount());
}
}
public void resetWeeklyMallPackagePurchases() {
this.resetMallCounterByRefreshType(this.getMallPackageBuyCount(), "mallPackageBuyCount", GameConstants.REFRESH_TYPE_WEEKLY);
}
public void resetMonthlyMallPackagePurchases() {
this.resetMallCounterByRefreshType(this.getMallPackageBuyCount(), "mallPackageBuyCount", GameConstants.REFRESH_TYPE_MONTHLY);
}
private void resetMallCounterByRefreshType(String2IntMap counter, String fieldName, int refreshType) {
if (counter == null || counter.isEmpty()) {
return;
}
var updated = new String2IntMap();
boolean changed = false;
for (var entry : counter.object2IntEntrySet()) {
var data = GameData.getMallPackageDataTable().get(Utils.unescapeKey(entry.getKey()).hashCode());
if (data != null && data.getRefreshType() == refreshType) {
changed = true;
continue;
}
updated.put(entry.getKey(), entry.getIntValue());
}
if (!changed) {
return;
}
counter.clear();
counter.putAll(updated);
Nebula.getGameDatabase().update(this, this.getUid(), fieldName, counter);
}
private int getMallCounterValue(String2IntMap counter, String key) {
if (counter == null || key == null) {
return 0;
}
int directValue = counter.get(key);
String escapedKey = Utils.escapeKey(key);
if (escapedKey.equals(key)) {
return directValue;
}
return Math.max(directValue, counter.get(escapedKey));
}
private void setMallCounterValue(String2IntMap counter, String key, int value) {
if (counter == null || key == null) {
return;
}
String escapedKey = Utils.escapeKey(key);
counter.put(escapedKey, value);
if (!escapedKey.equals(key)) {
counter.removeInt(key);
}
}
public int getMallPackagePurchaseCount(String packageId) {
return this.getMallCounterValue(this.getMallPackageBuyCount(), packageId);
}
public int getMallShopPurchaseCount(String shopId) {
return this.getMallCounterValue(this.getMallBuyCount(), shopId);
}
public boolean hasMallGemMaidenBonus(String gemId) {
return this.getMallCounterValue(this.getGemMaidenClaimed(), gemId) <= 0;
}
/**
* Dedicated toggle for MallGem first-purchase bonus state.
* Can reset this field later to reopen maiden bonus eligibility
*/
public void setMallGemMaidenClaimed(String gemId, boolean claimed) {
if (gemId == null) {
return;
}
if (this.gemMaidenClaimed == null) {
this.gemMaidenClaimed = new String2IntMap();
}
if (claimed) {
this.setMallCounterValue(this.getGemMaidenClaimed(), gemId, 1);
} else {
this.gemMaidenClaimed.removeInt(Utils.escapeKey(gemId));
this.gemMaidenClaimed.removeInt(gemId);
}
Nebula.getGameDatabase().update(this, getUid(), "gemMaidenClaimed", this.getGemMaidenClaimed());
}
/**
* Resets all MallGem maiden-bonus flags.
* This is the intended operation hook for anniversary-style first-purchase refreshes.
*/
public void resetMallGemMaidenBonuses() {
if (this.gemMaidenClaimed == null || this.gemMaidenClaimed.isEmpty()) {
return;
}
this.gemMaidenClaimed.clear();
Nebula.getGameDatabase().update(this, getUid(), "gemMaidenClaimed", this.getGemMaidenClaimed());
}
public int getMallMonthlyCardPurchaseCount(String cardId) {
return this.getMallCounterValue(this.getMonthlyCardBuyCount(), cardId);
}
public PlayerChangeInfo buyMallPackage(MallPackageDef data) {
var player = this.getPlayer();
if (!data.canPurchase(player)) {
return null;
}
int currencyItemId = data.getCurrencyItemId();
int currencyItemQty = data.getCurrencyItemQty();
if (data.isItemPackage() && currencyItemId > 0 && currencyItemQty > 0
&& !this.hasMallPackageCurrency(currencyItemId, currencyItemQty)) {
return null;
}
var change = new PlayerChangeInfo();
if (data.isItemPackage() && currencyItemId > 0 && currencyItemQty > 0) {
this.consumeMallPackageCurrency(currencyItemId, currencyItemQty, change);
}
this.addItems(data.getProducts(), change);
String packageId = data.getIdString();
int purchaseCount = this.getMallPackagePurchaseCount(packageId);
this.setMallCounterValue(this.getMallPackageBuyCount(), packageId, purchaseCount + 1);
Nebula.getGameDatabase().update(this, getUid(), "mallPackageBuyCount", this.getMallPackageBuyCount());
return change.setSuccess(true);
}
private boolean hasMallPackageCurrency(int currencyItemId, int currencyItemQty) {
if (currencyItemQty <= 0) {
return true;
}
if (currencyItemId == GameConstants.PAID_STELLANITE_LUMINA_ITEM_ID
|| currencyItemId == GameConstants.FREE_STELLANITE_LUMINA_ITEM_ID) {
return this.getResourceCount(GameConstants.PAID_STELLANITE_LUMINA_ITEM_ID)
+ this.getResourceCount(GameConstants.FREE_STELLANITE_LUMINA_ITEM_ID) >= currencyItemQty;
}
return this.hasItem(currencyItemId, currencyItemQty);
}
private void consumeMallPackageCurrency(int currencyItemId, int currencyItemQty, PlayerChangeInfo change) {
if (currencyItemId == GameConstants.PAID_STELLANITE_LUMINA_ITEM_ID
|| currencyItemId == GameConstants.FREE_STELLANITE_LUMINA_ITEM_ID) {
// use free stellanite lumina first.
int freeOwned = this.getResourceCount(GameConstants.FREE_STELLANITE_LUMINA_ITEM_ID);
int useFree = Math.min(freeOwned, currencyItemQty);
if (useFree > 0) {
this.removeItem(GameConstants.FREE_STELLANITE_LUMINA_ITEM_ID, useFree, change);
}
int remain = currencyItemQty - useFree;
if (remain > 0) {
this.removeItem(GameConstants.PAID_STELLANITE_LUMINA_ITEM_ID, remain, change);
}
return;
}
this.removeItem(currencyItemId, currencyItemQty, change);
}
public PlayerChangeInfo buyMallGem(MallGemDef data) {
var change = new PlayerChangeInfo();
this.addItems(data.buildProducts(this.getPlayer()), change);
this.setMallGemMaidenClaimed(data.getIdString(), true);
return change.setSuccess(true);
}
public PlayerChangeInfo buyMallMonthlyCard(MallMonthlyCardDef data) {
var change = new PlayerChangeInfo();
this.addItems(data.getProducts(), change);
String cardId = data.getIdString();
int purchaseCount = this.getMallMonthlyCardPurchaseCount(cardId);
this.setMallCounterValue(this.getMonthlyCardBuyCount(), cardId, purchaseCount + 1);
Nebula.getGameDatabase().update(this, getUid(), "monthlyCardBuyCount", this.getMonthlyCardBuyCount());
return change.setSuccess(true);
}
// Database
@SuppressWarnings("deprecation")
@@ -990,7 +1216,22 @@ public class Inventory extends PlayerManager implements GameDatabaseObject {
// Set to save inventory to database
save = true;
}
if (this.mallPackageBuyCount == null) {
this.mallPackageBuyCount = new String2IntMap();
save = true;
}
if (this.gemMaidenClaimed == null) {
this.gemMaidenClaimed = new String2IntMap();
save = true;
}
if (this.monthlyCardBuyCount == null) {
this.monthlyCardBuyCount = new String2IntMap();
save = true;
}
// Update in database
if (save) {
this.save();
@@ -54,10 +54,10 @@ public class ItemParamMap extends Int2IntLinkedOpenHashMap implements ObjectBidi
/**
* Returns a new ItemParamMap with item amounts multiplied
* @param mult Value to multiply all item amounts in this map by
* @return
* @param multiplier Value to multiply all item amounts in this map by
* @return ItemParamMap
*/
public ItemParamMap mulitply(int multiplier) {
public ItemParamMap multiply(int multiplier) {
var params = new ItemParamMap();
for (var entry : this.int2IntEntrySet()) {
@@ -0,0 +1,48 @@
package emu.nebula.game.mall;
import emu.nebula.proto.Public.ChangeInfo;
import lombok.Getter;
/**
* Separate real reward delta from popup display data
* Decouple settlement logic from client UI, prevent data mutation on reuse
*/
@Getter
public final class MallOrderCollectResult {
private final ChangeInfo stateChange;
private final ChangeInfo displayChange;
private MallOrderCollectResult(ChangeInfo stateChange, ChangeInfo displayChange) {
this.stateChange = copyOf(stateChange);
this.displayChange = copyOf(displayChange);
}
public static MallOrderCollectResult empty() {
return new MallOrderCollectResult(null, null);
}
public static MallOrderCollectResult of(ChangeInfo change) {
return new MallOrderCollectResult(change, change);
}
public static MallOrderCollectResult split(ChangeInfo stateChange, ChangeInfo displayChange) {
return new MallOrderCollectResult(stateChange, displayChange);
}
public boolean hasStateChange() {
return !stateChange.isEmpty();
}
public boolean hasDisplayChange() {
return !displayChange.isEmpty();
}
private static ChangeInfo copyOf(ChangeInfo change) {
if (change == null || change.isEmpty()) {
return ChangeInfo.newInstance();
}
return ChangeInfo.newInstance().copyFrom(change);
}
}
@@ -0,0 +1,338 @@
package emu.nebula.game.mall;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import emu.nebula.Nebula;
import emu.nebula.data.resources.MallPackageDef;
import emu.nebula.data.resources.MallGemDef;
import emu.nebula.data.resources.MallMonthlyCardDef;
import emu.nebula.data.resources.BattlePassDef;
import emu.nebula.data.resources.DropPkgDef;
import emu.nebula.game.GameContext;
import emu.nebula.game.GameContextModule;
import emu.nebula.game.inventory.ItemParamMap;
import emu.nebula.game.player.PlayerChangeInfo;
import emu.nebula.net.GameSession;
import emu.nebula.net.NetMsgId;
import emu.nebula.net.PacketHelper;
import emu.nebula.proto.MallGemOrder.OrderInfo;
import emu.nebula.proto.Public.HeadIcon;
import emu.nebula.proto.Notify.OrderStateChange;
import emu.nebula.proto.Public.Item;
import emu.nebula.proto.Public.ChangeInfo;
import emu.nebula.proto.Public.Res;
import emu.nebula.util.JsonUtils;
/**
* Mock payment, isolate mock order data
*/
public class PayModule extends GameContextModule {
public PayModule(GameContext context) {
super(context);
}
@FunctionalInterface
private interface CollectSettlement {
MallOrderCollectResult resolve(GameSession session) throws Exception;
}
private final ConcurrentHashMap<Integer, CollectSettlement> pendingCollects = new ConcurrentHashMap<>();
private volatile Map<Integer, int[]> battlePassDropPackages;
public void clearPendingCollect(int uid) {
pendingCollects.remove(uid);
}
/**
* Resolves the authoritative collect payload and clears the transient order state.
*/
public MallOrderCollectResult consumePendingCollect(GameSession session) throws Exception {
CollectSettlement settlement = pendingCollects.remove(session.getPlayer().getUid());
if (settlement == null) {
return null;
}
return settlement.resolve(session);
}
/**
* Keeps mock-order flows aligned with normal inventory updates by reusing the
* same item notify packets as the rest of the server.
*/
public void pushInventoryNotifies(GameSession session, ChangeInfo items) {
if (items == null || items.isEmpty()) {
return;
}
session.getPlayer().addNextPackage(NetMsgId.items_change_notify, items);
}
/**
* Use mock payment for gem recharge to match official mall order flow
*/
public OrderInfo createGemOrder(GameSession session, MallGemDef data) {
return this.createOrder(session, "gem", data.getIdString(), collectSession -> {
var change = collectSession.getPlayer().getInventory().buyMallGem(data);
if (change == null) {
return MallOrderCollectResult.empty();
}
return MallOrderCollectResult.of(change.toProto());
});
}
/**
* Defer monthly card settlement to maintain consistent mock-payment lifecycle with mall flow.
*/
public OrderInfo createMonthlyCardOrder(GameSession session, MallMonthlyCardDef data) {
var player = session.getPlayer();
if (data == null || !data.canPurchase(player)) {
return null;
}
int durationDays = data.getMonthlyCardDuration();
return this.createOrder(session, "monthly", data.getIdString(), collectSession -> {
var collectPlayer = collectSession.getPlayer();
var collectChange = collectPlayer.getInventory().buyMallMonthlyCard(data);
if (collectChange == null) {
return MallOrderCollectResult.empty();
}
collectPlayer.activateMonthlyCard(data.getIdString(), durationDays);
var dailyRewardChange = collectPlayer.createMonthlyCardRewardChange(data.getIdString());
if (dailyRewardChange != null) {
collectChange.add(dailyRewardChange);
}
return MallOrderCollectResult.of(collectChange.toProto());
});
}
/**
* Cash mall packages follow the same order -> paid notify -> collect lifecycle as other RMB products.
*/
public OrderInfo createPackageOrder(GameSession session, MallPackageDef data) {
if (data == null || !data.isCashPackage() || !data.canPurchase(session.getPlayer())) {
return null;
}
return this.createOrder(session, "package", data.getIdString(), collectSession -> {
var change = collectSession.getPlayer().getInventory().buyMallPackage(data);
if (change == null) {
return MallOrderCollectResult.empty();
}
return MallOrderCollectResult.of(change.toProto());
});
}
/**
* Create mock battle pass order following standard payment flow:
* order confirm -> payment notify -> reward settlement.
*/
public OrderInfo createBattlePassOrder(GameSession session, BattlePassDef data, int requestedMode) {
return this.createOrder(session, "battlepass", data.getId() + ":" + requestedMode, collectSession -> {
var player = collectSession.getPlayer();
var battlePass = player.getBattlePassManager().getBattlePass();
if (battlePass == null) {
return MallOrderCollectResult.empty();
}
int currentMode = battlePass.getMode();
if (requestedMode <= currentMode) {
return MallOrderCollectResult.empty();
}
battlePass.setMode(requestedMode);
if (requestedMode == 2 && data.getLuxuryBonusLevel() > 0) {
battlePass.setLevel(battlePass.getLevel() + data.getLuxuryBonusLevel());
}
var rewards = new ItemParamMap();
int collectItemId = 0;
int collectItemQty = 0;
// BattlePass.json semantics:
// - mode=1 (Premium) unlocks premium claim lane only, no immediate collect reward.
// - mode=2 (Luxury) grants immediate luxury reward.
// - mode=1 -> mode=2 upgrade uses complementary reward config.
if (requestedMode == 2) {
if (currentMode == 0) {
collectItemId = data.getLuxuryTid();
collectItemQty = data.getLuxuryQty();
} else if (currentMode == 1) {
collectItemId = data.getComplementaryTid();
collectItemQty = data.getComplementaryQty();
}
}
this.addBattlePassCollectRewards(rewards, collectItemId, collectItemQty);
battlePass.save();
if (rewards.isEmpty()) {
return MallOrderCollectResult.empty();
}
var change = player.getInventory().addItems(rewards);
if (change == null || change.isEmpty()) {
return MallOrderCollectResult.empty();
}
var stateChange = change.toProto();
var displayChange = this.buildBattlePassDisplayChange(change);
return MallOrderCollectResult.split(stateChange, displayChange);
});
}
/**
* Resolves battle-pass collect rewards.
* <p>
* Some battle-pass collect ids in Item.json are intermediate "drop keys" that
* should expand to concrete rewards (for example, skin/head icon), otherwise
* the client misses skin-gain specific reward presentation.
*/
private void addBattlePassCollectRewards(ItemParamMap rewards, int collectItemId, int collectItemQty) {
if (collectItemId <= 0 || collectItemQty <= 0) {
return;
}
var packageIds = this.getBattlePassDropPackages().get(collectItemId);
if (packageIds == null || packageIds.length == 0) {
rewards.add(collectItemId, collectItemQty);
return;
}
for (int i = 0; i < collectItemQty; i++) {
for (int packageId : packageIds) {
int dropItemId = DropPkgDef.getRandomDrop(packageId);
if (dropItemId > 0) {
rewards.add(dropItemId, 1);
}
}
}
}
/**
* Loads battle-pass drop-package mapping from Drop.json on first use and caches it.
* Mapping rule: DropId -> [PkgId...].
*/
private Map<Integer, int[]> getBattlePassDropPackages() {
var cache = this.battlePassDropPackages;
if (cache != null) {
return cache;
}
synchronized (this) {
cache = this.battlePassDropPackages;
if (cache != null) {
return cache;
}
cache = this.loadBattlePassDropPackages();
this.battlePassDropPackages = cache;
return cache;
}
}
private Map<Integer, int[]> loadBattlePassDropPackages() {
final String dropPath = Nebula.getConfig().resourceDir + "/bin/Drop.json";
var result = new LinkedHashMap<Integer, int[]>();
try {
var rows = JsonUtils.loadToMap(dropPath, String.class, DropRow.class);
if (rows == null || rows.isEmpty()) {
return Collections.emptyMap();
}
var aggregate = new LinkedHashMap<Integer, List<Integer>>();
for (var row : rows.values()) {
if (row == null || row.DropId <= 0 || row.PkgId <= 0) {
continue;
}
aggregate.computeIfAbsent(row.DropId, ignored -> new ArrayList<>()).add(row.PkgId);
}
for (var entry : aggregate.entrySet()) {
var values = entry.getValue();
if (values == null || values.isEmpty()) {
continue;
}
int[] packageIds = new int[values.size()];
for (int i = 0; i < values.size(); i++) {
packageIds[i] = values.get(i);
}
result.put(entry.getKey(), packageIds);
}
} catch (Exception exception) {
Nebula.getLogger().error("Failed to load battle-pass drop mapping from {}", dropPath, exception);
return Collections.emptyMap();
}
return result;
}
/**
* Minimal Drop.json row shape needed for battle-pass mapping.
*/
private static final class DropRow {
private int DropId;
private int PkgId;
}
/**
* Battle-pass collect triggers 3 client presentations:
* skin animation (notify queue), normal reward popup (CollectResp.Items), level refresh (main response).
* <p>
* Only popup-safe items are shown in reward popup to avoid duplicate skin/head display.
* Avatar rewards are converted to item format for the client's general reward popup.
*/
private ChangeInfo buildBattlePassDisplayChange(PlayerChangeInfo change) {
if (change == null || change.isEmpty()) {
return ChangeInfo.newInstance();
}
var display = new PlayerChangeInfo();
for (var any : change.getList()) {
String typeUrl = any.getTypeUrl();
if (typeUrl.endsWith(Item.class.getSimpleName()) || typeUrl.endsWith(Res.class.getSimpleName())) {
display.getList().add(any.clone());
continue;
}
if (typeUrl.endsWith(HeadIcon.class.getSimpleName())) {
try {
var headIcon = HeadIcon.parseFrom(any.getValue().toArray());
display.add(Item.newInstance().setTid(headIcon.getTid()).setQty(1));
} catch (Exception ignored) {
// ignore
}
}
}
return display.toProto();
}
private OrderInfo createOrder(GameSession session, String orderType, String payloadKey, CollectSettlement settlement) {
int uid = session.getPlayer().getUid();
String orderId = orderType + "." + uid + "." + System.currentTimeMillis();
this.pendingCollects.put(uid, settlement);
return this.buildMockOrder(orderId, orderType + ":" + payloadKey + ":" + orderId);
}
private OrderInfo buildMockOrder(String orderId, String extraData) {
var paidNotify = OrderStateChange.newInstance()
.setOrderId(orderId)
.setStore(orderId.startsWith("battlepass.") ? 3 : 1);
return OrderInfo.newInstance()
.setId(orderId)
.setExtraData(extraData)
.setNotifyUrl(String.format("http://localhost:%s/mock-pay", Nebula.getConfig().getHttpServer().getBindPort()))
.setNextPackage(PacketHelper.encodeMsg(NetMsgId.order_paid_notify, paidNotify));
}
}
+274 -35
View File
@@ -1,15 +1,13 @@
package emu.nebula.game.player;
import java.util.Stack;
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;
import emu.nebula.data.resources.MallMonthlyCardDef;
import emu.nebula.database.GameDatabaseObject;
import emu.nebula.game.account.Account;
import emu.nebula.game.achievement.AchievementCondition;
@@ -35,27 +33,23 @@ import emu.nebula.game.vampire.VampireSurvivorManager;
import emu.nebula.net.GameSession;
import emu.nebula.net.NetMsgId;
import emu.nebula.net.NetMsgPacket;
import emu.nebula.proto.Notify.MonthlyCardRewards;
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.CharShow;
import emu.nebula.proto.Public.Energy;
import emu.nebula.proto.Public.Friend;
import emu.nebula.proto.Public.HonorInfo;
import emu.nebula.proto.Public.Item;
import emu.nebula.proto.Public.NewbieInfo;
import emu.nebula.proto.Public.QuestType;
import emu.nebula.proto.Public.Res;
import emu.nebula.proto.Public.WorldClass;
import emu.nebula.proto.Public.WorldClassRewardState;
import emu.nebula.proto.Public;
import emu.nebula.proto.Public.*;
import emu.nebula.util.ResetCycle;
import emu.nebula.util.Utils;
import emu.nebula.proto.Public.Title;
import emu.nebula.util.ints.String2IntMap;
import lombok.Getter;
import us.hebi.quickbuf.ProtoMessage;
import us.hebi.quickbuf.RepeatedInt;
import java.lang.String;
import java.util.Stack;
@Getter
@Entity(value = "players", useDiscriminator = false)
public class Player implements GameDatabaseObject {
@@ -92,6 +86,8 @@ public class Player implements GameDatabaseObject {
private long lastEpochDay;
private long lastLogin;
private long createTime;
private String2IntMap monthlyCardExpireDays;
private String2IntMap monthlyCardLastRewardDays;
// Managers
private final transient CharacterStorage characters;
@@ -165,6 +161,8 @@ public class Player implements GameDatabaseObject {
this.level = 1;
this.energy = 240;
this.energyLastUpdate = this.createTime;
this.monthlyCardExpireDays = new String2IntMap();
this.monthlyCardLastRewardDays = new String2IntMap();
// Setup inventory
this.inventory = new Inventory(this);
@@ -217,6 +215,8 @@ public class Player implements GameDatabaseObject {
}
public void setLevel(int level) {
int oldLevel = this.level;
// Set player world class (level)
this.level = level;
@@ -225,6 +225,10 @@ public class Player implements GameDatabaseObject {
// Trigger achievement
this.trigger(AchievementCondition.WorldClassSpecific, this.getLevel());
if (oldLevel != this.level) {
this.queueBattlePassUnlockNotify(oldLevel);
}
}
public void setExp(int exp) {
@@ -547,6 +551,8 @@ public class Player implements GameDatabaseObject {
// Trigger achievement
this.trigger(AchievementCondition.WorldClassSpecific, this.getLevel());
this.queueBattlePassUnlockNotify(oldLevel);
}
// Calculate changes
@@ -555,7 +561,7 @@ public class Player implements GameDatabaseObject {
.setExpChange(this.getExp() - oldExp);
changes.add(proto);
return changes;
}
@@ -637,26 +643,29 @@ public class Player implements GameDatabaseObject {
// Dailies
public void checkResetDailies() {
// Sanity check to make sure daily reset isnt being triggered wrong
if (Nebula.getGameContext().getEpochDays() <= this.getLastEpochDay()) {
long currentResetDay = Utils.getResetEpochDay();
// Sanity check to make sure daily reset isn't being triggered wrong
if (currentResetDay <= this.getLastEpochDay()) {
// Fix sign-in index
// TODO remove later
if (this.getSignInIndex() <= 0) {
this.getSignInRewards(false);
}
this.refreshMonthlyCardRewards(false);
// End
return;
}
// Check if week has changed (Resets on monday)
// TODO add a config option
int curWeek = Utils.getWeeks(this.getLastEpochDay());
boolean hasWeekChanged = Nebula.getGameContext().getEpochWeeks() > curWeek;
// Check if week has changed (Resets on Monday)
int curWeek = Utils.getResetPeriodIndex(ResetCycle.WEEKLY, this.getLastResetDayTimeSeconds());
boolean hasWeekChanged = Utils.getResetPeriodIndex(ResetCycle.WEEKLY, Nebula.getCurrentServerTime()) > curWeek;
// Check if month was changed
int curMonth = Utils.getMonths(this.getLastEpochDay());
boolean hasMonthChanged = Nebula.getGameContext().getEpochMonths() > curMonth;
int curMonth = Utils.getResetPeriodIndex(ResetCycle.MONTHLY, this.getLastResetDayTimeSeconds());
boolean hasMonthChanged = Utils.getResetPeriodIndex(ResetCycle.MONTHLY, Nebula.getCurrentServerTime()) > curMonth;
// Reset dailies
this.resetDailies(hasWeekChanged, hasMonthChanged);
@@ -666,9 +675,10 @@ public class Player implements GameDatabaseObject {
// Give sign-in rewards
this.getSignInRewards(hasMonthChanged);
this.refreshMonthlyCardRewards(true);
// Update last epoch day
this.lastEpochDay = Nebula.getGameContext().getEpochDays();
this.lastEpochDay = currentResetDay;
Nebula.getGameDatabase().update(this, this.getUid(), "lastEpochDay", this.lastEpochDay);
}
@@ -680,7 +690,7 @@ public class Player implements GameDatabaseObject {
// Get next sign-in index
int nextSignIn = this.signInIndex + 1;
int group = Utils.getDaysOfMonth(this.getLastEpochDay());
int group = Utils.getDaysOfMonth(Utils.getResetEpochDay());
var data = GameData.getSignInDataTable().get((group << 16) + nextSignIn);
if (data == null) {
@@ -725,12 +735,14 @@ public class Player implements GameDatabaseObject {
// Reset weekly tower tickets
this.getProgress().clearWeeklyTowerTicketLog();
this.getInventory().resetWeeklyMallPackagePurchases();
}
// Check if we need to reset monthly
if (resetMonthly) {
// Reset monthly shop purchases
this.getInventory().resetShopPurchases();
this.getInventory().resetMonthlyMallPackagePurchases();
}
}
@@ -809,6 +821,16 @@ public class Player implements GameDatabaseObject {
this.showChars = new int[3];
this.save();
}
if (this.monthlyCardExpireDays == null) {
this.monthlyCardExpireDays = new String2IntMap();
Nebula.getGameDatabase().update(this, this.getUid(), "monthlyCardExpireDays", this.monthlyCardExpireDays);
}
if (this.monthlyCardLastRewardDays == null) {
this.monthlyCardLastRewardDays = new String2IntMap();
Nebula.getGameDatabase().update(this, this.getUid(), "monthlyCardLastRewardDays", this.monthlyCardLastRewardDays);
}
// Init activities
this.getActivityManager().init();
@@ -829,13 +851,225 @@ public class Player implements GameDatabaseObject {
// Fix any broken honor ids
this.checkBrokenHonor();
// Update activities
this.getActivityManager().onLogin();
// Update last login time
this.lastLogin = System.currentTimeMillis();
Nebula.getGameDatabase().update(this, this.getUid(), "lastLogin", this.getLastLogin());
}
/**
* Returns whether the player currently has at least one purchasable
* free mall package (`CurrencyType = 3`) for the mall entrance red dot.
*/
public boolean hasAvailableFreeMallPackage() {
for (var data : GameData.getMallPackageDataTable()) {
if (!data.isFreePackage()) {
continue;
}
if (data.canPurchase(this)) {
return true;
}
}
return false;
}
/**
* Queues the mall entrance red dot state for free mall packages (`CurrencyType = 3`).
* Specific package entries are still refreshed from the package list itself.
*/
public void queueMallPackageStateNotify() {
this.addNextPackage(
NetMsgId.mall_package_state_notify,
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);
}
/**
* Returns the number of future game days still covered after the current game day.
* On the last claimable day this value is 0 even though today's reward can still be claimed.
*/
public int getMonthlyCardRemainingDays(String cardId) {
if (this.monthlyCardExpireDays == null) {
return 0;
}
int endDay = this.monthlyCardExpireDays.get(cardId);
long currentDay = Utils.getResetEpochDay();
if (endDay < currentDay) {
return 0;
}
return (int) (endDay - currentDay);
}
public boolean receivedMonthlyCardRewardToday(String cardId) {
return cardId != null
&& this.monthlyCardLastRewardDays != null
&& this.monthlyCardLastRewardDays.get(cardId) >= Utils.getResetEpochDay();
}
public long getMonthlyCardEndTime(String cardId) {
if (cardId == null || this.monthlyCardExpireDays == null) {
return 0;
}
int endDay = this.monthlyCardExpireDays.get(cardId);
if (endDay <= 0) {
return 0;
}
return Utils.getResetTimeSecondsByEpochDay((long) endDay + 1);
}
private void setMonthlyCardRewardEndDay(String cardId, int endDay) {
if (this.monthlyCardExpireDays == null) {
this.monthlyCardExpireDays = new String2IntMap();
}
this.monthlyCardExpireDays.put(cardId, endDay);
Nebula.getGameDatabase().update(this, this.getUid(), "monthlyCardExpireDays", this.monthlyCardExpireDays);
}
private void setMonthlyCardLastRewardDay(String cardId, int rewardDay) {
if (this.monthlyCardLastRewardDays == null) {
this.monthlyCardLastRewardDays = new String2IntMap();
}
this.monthlyCardLastRewardDays.put(cardId, rewardDay);
Nebula.getGameDatabase().update(this, this.getUid(), "monthlyCardLastRewardDays", this.monthlyCardLastRewardDays);
}
public void activateMonthlyCard(String cardId, int durationDays) {
if (cardId == null || durationDays <= 0) {
return;
}
int currentDay = Math.toIntExact(Utils.getResetEpochDay());
int remainingDays = this.getMonthlyCardRemainingDays(cardId);
boolean claimedToday = this.receivedMonthlyCardRewardToday(cardId);
// remainingDays:
// - > 0: directly add durationDays
// - == 0 and claimed today: add full durationDays
// - == 0 and not claimed today: purchase auto-claims today's reward, so keep durationDays - 1 future days
int newRemainingDays = remainingDays > 0 || claimedToday
? remainingDays + durationDays
: Math.max(durationDays - 1, 0);
this.setMonthlyCardRewardEndDay(cardId, currentDay + newRemainingDays);
}
public PlayerChangeInfo createMonthlyCardRewardChange(String cardId) {
var cardData = this.getMonthlyCardData(cardId);
if (cardData == null || !this.canClaimMonthlyCardReward(cardId)) {
return null;
}
var rewards = cardData.getDailyRewards();
if (rewards.isEmpty()) {
return null;
}
var change = this.getInventory().addItems(rewards);
this.setMonthlyCardLastRewardDay(cardId, Math.toIntExact(Utils.getResetEpochDay()));
return change;
}
public void grantMonthlyCardReward(String cardId, boolean notifyOnly) {
var cardData = this.getMonthlyCardData(cardId);
if (cardData == null) {
return;
}
var change = this.createMonthlyCardRewardChange(cardId);
if (change == null) {
return;
}
var rewards = cardData.getDailyRewards();
var notify = MonthlyCardRewards.newInstance()
.setId(cardData.getMonthlyCardId())
.setRemaining(this.getMonthlyCardRemainingDays(cardId))
.setSwitch(!notifyOnly)
.setEndTime(this.getMonthlyCardEndTime(cardId));
var changeProto = change.toProto();
notify.setChange(changeProto);
rewards.toItemTemplateStream().forEach(notify::addRewards);
this.addNextPackage(NetMsgId.monthly_card_rewards_notify, notify);
if (!changeProto.isEmpty()) {
this.addNextPackage(NetMsgId.items_change_notify, changeProto);
}
}
private MallMonthlyCardDef getMonthlyCardData(String cardId) {
return cardId == null ? null : GameData.getMallMonthlyCardDataTable().get(cardId.hashCode());
}
private boolean canClaimMonthlyCardReward(String cardId) {
if (this.monthlyCardExpireDays == null) {
return false;
}
int today = Math.toIntExact(Utils.getResetEpochDay());
int endDay = this.monthlyCardExpireDays.get(cardId);
return endDay > 0 && today <= endDay && !this.receivedMonthlyCardRewardToday(cardId);
}
private void refreshMonthlyCardRewards(boolean notifyOnly) {
if (this.monthlyCardExpireDays == null || this.monthlyCardExpireDays.isEmpty()) {
return;
}
for (var entry : this.monthlyCardExpireDays.object2IntEntrySet()) {
this.grantMonthlyCardReward(entry.getKey(), notifyOnly);
}
}
private long getLastResetDayTimeSeconds() {
return Utils.getResetTimeSecondsByEpochDay(this.getLastEpochDay());
}
// Next packages
@@ -846,7 +1080,7 @@ public class Player implements GameDatabaseObject {
public void addNextPackage(int msgId, ProtoMessage<?> proto) {
this.getNextPackages().add(new NetMsgPacket(msgId, proto));
}
// Misc
/**
@@ -984,8 +1218,9 @@ public class Player implements GameDatabaseObject {
state.getMutableMail()
.setNew(this.getMailbox().hasNewMail());
state.getMutableBattlePass()
.setState(this.getBattlePassManager().hasNew() ? 1 : 0);
// 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.getMutableAchievement()
.setNew(this.getAchievementManager().hasNewAchievements());
@@ -1073,7 +1308,7 @@ public class Player implements GameDatabaseObject {
// Complete
return proto;
}
public Friend getFriendProto() {
var proto = Friend.newInstance()
.setId(this.getUid())
@@ -1115,5 +1350,9 @@ public class Player implements GameDatabaseObject {
return proto;
}
}
public boolean isBattlePassUnlocked() {
return this.getLevel() >= GameConstants.BATTLE_PASS_UNLOCK_LEVEL;
}
}
@@ -1,5 +1,8 @@
package emu.nebula.game.player;
import lombok.Getter;
@Getter
public abstract class PlayerManager {
private transient Player player;
@@ -11,10 +14,6 @@ public abstract class PlayerManager {
this.player = player;
}
public Player getPlayer() {
return this.player;
}
public void setPlayer(Player player) {
if (this.player == null) {
this.player = player;
@@ -1,6 +1,8 @@
package emu.nebula.game.quest;
import dev.morphia.annotations.Entity;
import emu.nebula.util.Utils;
import emu.nebula.util.ResetCycle;
import emu.nebula.proto.Public.Quest;
import emu.nebula.proto.Public.QuestProgress;
import lombok.Getter;
@@ -103,7 +105,21 @@ public class GameQuest {
.setTypeValue(this.getType())
.setStatus(this.getStatus())
.addProgress(progress);
long expire = this.getExpireTime();
if (expire > 0) {
proto.setExpire(expire);
}
return proto;
}
private long getExpireTime() {
return switch (this.type) {
case QuestType.BattlePassDaily -> Utils.getNextResetTimeSeconds(ResetCycle.DAILY);
case QuestType.BattlePassWeekly -> Utils.getNextResetTimeSeconds(ResetCycle.WEEKLY);
default -> 0L;
};
}
}
@@ -148,11 +148,12 @@ public enum QuestCondition {
}
}
private QuestCondition(int value) {
QuestCondition(int value) {
this.value = value;
}
public static QuestCondition getByValue(int value) {
return map.get(value);
}
}