diff --git a/.gitignore b/.gitignore index c27273a..ba8d8bb 100644 --- a/.gitignore +++ b/.gitignore @@ -76,7 +76,7 @@ tmp/ # Extra Nebula Handbook.txt -config.json +data/* patchlist.json *.mv *.exe diff --git a/src/main/java/emu/nebula/Nebula.java b/src/main/java/emu/nebula/Nebula.java index 65bad60..d9fef6d 100644 --- a/src/main/java/emu/nebula/Nebula.java +++ b/src/main/java/emu/nebula/Nebula.java @@ -1,20 +1,12 @@ package emu.nebula; -import java.io.*; -import java.text.SimpleDateFormat; -import java.util.Date; -import java.util.Map; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - import com.google.gson.Gson; import com.google.gson.GsonBuilder; - import emu.nebula.command.CommandManager; import emu.nebula.data.ResourceLoader; import emu.nebula.database.DatabaseManager; import emu.nebula.game.GameContext; +import emu.nebula.game.gacha.GachaDataMigration; import emu.nebula.net.PacketHelper; import emu.nebula.plugin.PluginManager; import emu.nebula.server.HttpServer; @@ -22,6 +14,13 @@ import emu.nebula.util.AeadHelper; import emu.nebula.util.Handbook; import emu.nebula.util.JsonUtils; import lombok.Getter; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.*; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Map; public class Nebula { private static final Logger log = LoggerFactory.getLogger(Nebula.class); @@ -48,20 +47,23 @@ public class Nebula { public static void main(String[] args) { // Start Server - Nebula.getLogger().info("Starting Nebula " + getJarVersion()); - Nebula.getLogger().info("Git hash: " + getGitHash()); + Nebula.getLogger().info("Starting Nebula {}", getJarVersion()); + Nebula.getLogger().info("Git hash: {}", getGitHash()); // Create data directory if it doesn't exist yet if (!dataDir.exists()) { - dataDir.mkdirs(); + boolean mkDataDirResult = dataDir.mkdirs(); + if (!mkDataDirResult) { + Nebula.getLogger().error("Failed to create data directory {}", dataDir.getAbsolutePath()); + } } - + // Load config and data versions first Nebula.loadConfig(); Nebula.loadDataVersions(); // Output game version - Nebula.getLogger().info("Game version: " + GameConstants.getGameVersion()); + Nebula.getLogger().info("Game version: {}", GameConstants.getGameVersion()); // Load keys AeadHelper.loadKeys(); @@ -91,7 +93,7 @@ public class Nebula { case "-database": // Database only DatabaseManager.startInternalMongoServer(Nebula.getConfig().getInternalMongoServer()); - Nebula.getLogger().info("Running local Mongo server at " + DatabaseManager.getServer().getConnectionString()); + Nebula.getLogger().info("Running local Mongo server at {}", DatabaseManager.getServer().getConnectionString()); return; } } @@ -114,6 +116,10 @@ public class Nebula { } catch (Exception exception) { Nebula.getLogger().error("Unable to start the database(s).", exception); } + + if (serverType.runGame() && Nebula.getGameDatabase() != null) { + GachaDataMigration.run(); + } // Start game context Nebula.gameContext = new GameContext(); diff --git a/src/main/java/emu/nebula/data/GameData.java b/src/main/java/emu/nebula/data/GameData.java index ad65e51..01678a0 100644 --- a/src/main/java/emu/nebula/data/GameData.java +++ b/src/main/java/emu/nebula/data/GameData.java @@ -1,19 +1,7 @@ package emu.nebula.data; -import java.lang.reflect.Field; - -import java.util.Arrays; -import java.util.List; -import java.util.ArrayList; - -import java.util.stream.Collectors; - -import it.unimi.dsi.fastutil.ints.*; -import it.unimi.dsi.fastutil.objects.Object2ObjectMap; -import it.unimi.dsi.fastutil.objects.Object2ObjectOpenHashMap; import emu.nebula.data.custom.CharGemAttrGroupDef; import emu.nebula.data.resources.*; - import lombok.Getter; @SuppressWarnings("unused") @@ -87,8 +75,11 @@ public class GameData { @Getter private static DataTable DictionaryEntryDataTable = new DataTable<>(); // ===== Gacha ===== + @Getter private static DataTable GachaATypeProbDataTable = new DataTable<>(); @Getter private static DataTable GachaDataTable = new DataTable<>(); + @Getter private static DataTable GachaNewbieDataTable = new DataTable<>(); @Getter private static DataTable GachaStorageDataTable = new DataTable<>(); + @Getter private static DataTable GachaTypeDataTable = new DataTable<>(); // ===== Story ===== @Getter private static DataTable StoryDataTable = new DataTable<>(); @@ -165,7 +156,7 @@ public class GameData { // Activity: Trials @Getter private static DataTable TrialControlDataTable = new DataTable<>(); @Getter private static DataTable TrialGroupDataTable = new DataTable<>(); - + // Activity: Joint Drill @Getter private static DataTable JointDrill2LevelDataTable = new DataTable<>(); @@ -175,7 +166,7 @@ public class GameData { // Activity: Task @Getter private static DataTable ActivityTaskDataTable = new DataTable<>(); @Getter private static DataTable ActivityTaskGroupDataTable = new DataTable<>(); - + // Activity: Shop @Getter private static DataTable ActivityShopDataTable = new DataTable<>(); @Getter private static DataTable ActivityShopControlDataTable = new DataTable<>(); diff --git a/src/main/java/emu/nebula/data/ResourceLoader.java b/src/main/java/emu/nebula/data/ResourceLoader.java index 6031612..e9a6bae 100644 --- a/src/main/java/emu/nebula/data/ResourceLoader.java +++ b/src/main/java/emu/nebula/data/ResourceLoader.java @@ -94,11 +94,10 @@ public class ResourceLoader { } } } catch (Exception e) { - e.printStackTrace(); - Nebula.getLogger().error("Error loading resource file: " + type.name(), e); + Nebula.getLogger().error("Error loading resource file: {}", type.name(), e); } - - Nebula.getLogger().info("Loaded " + count + " " + resourceClass.getSimpleName() + "s."); + + Nebula.getLogger().info("Loaded {} {}s.", count, resourceClass.getSimpleName()); } // Utility @@ -132,7 +131,7 @@ public class ResourceLoader { field.setAccessible(true); table = (DataTable) field.get(null); } catch (Exception e) { - + // ignore exception } finally { field.setAccessible(false); } diff --git a/src/main/java/emu/nebula/data/resources/GachaATypeProbDef.java b/src/main/java/emu/nebula/data/resources/GachaATypeProbDef.java new file mode 100644 index 0000000..120fba2 --- /dev/null +++ b/src/main/java/emu/nebula/data/resources/GachaATypeProbDef.java @@ -0,0 +1,64 @@ +package emu.nebula.data.resources; + +import emu.nebula.data.BaseDef; +import emu.nebula.data.ResourceType; +import emu.nebula.data.ResourceType.LoadPriority; +import it.unimi.dsi.fastutil.ints.Int2IntMap; +import it.unimi.dsi.fastutil.ints.Int2IntOpenHashMap; +import it.unimi.dsi.fastutil.ints.Int2ObjectMap; +import it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap; +import it.unimi.dsi.fastutil.ints.IntCollection; +import lombok.Getter; + +@Getter +@ResourceType(name = "GachaATypeProb.json", loadPriority = LoadPriority.HIGH) +public class GachaATypeProbDef extends BaseDef { + private int Group; + private int Times; + private int Prob; + + private static final Int2ObjectMap probByGroupAndTimes = new Int2ObjectOpenHashMap<>(); + + @Override + public int getId() { + // Compose a key from (Group, Times): high 16 bits = Group, low 16 bits = Times. + return (Group << 16) | (Times & 0xFFFF); + } + + @Override + public void onLoad() { + // Indexed as group (miss times -> probability) for O(1) runtime lookup + var groupMap = probByGroupAndTimes.computeIfAbsent(this.Group, i -> new Int2IntOpenHashMap()); + groupMap.put(this.Times, this.Prob); + } + + public static int getProb(int group, int times, int fallback) { + var groupMap = probByGroupAndTimes.get(group); + if (groupMap == null) { + return fallback; + } + + if (groupMap.containsKey(times)) { + return groupMap.get(times); + } + + // Times=0 is treated as the group's default curve point. + return groupMap.getOrDefault(0, fallback); + } + + public static int getMaxProb() { + int max = 0; + // Used by the roll engine to scale random range safely when config max exceeds 10000. + for (var groupMap : probByGroupAndTimes.values()) { + IntCollection values = groupMap.values(); + for (var valueIterator = values.iterator(); valueIterator.hasNext();) { + int value = valueIterator.nextInt(); + if (value > max) { + max = value; + } + } + } + + return max; + } +} diff --git a/src/main/java/emu/nebula/data/resources/GachaDef.java b/src/main/java/emu/nebula/data/resources/GachaDef.java index 2f5da20..4a209ac 100644 --- a/src/main/java/emu/nebula/data/resources/GachaDef.java +++ b/src/main/java/emu/nebula/data/resources/GachaDef.java @@ -1,13 +1,18 @@ package emu.nebula.data.resources; +import emu.nebula.Nebula; import emu.nebula.data.BaseDef; import emu.nebula.data.GameData; import emu.nebula.data.ResourceType; +import emu.nebula.data.ResourceType.LoadPriority; +import emu.nebula.util.Utils; import emu.nebula.util.WeightedList; +import it.unimi.dsi.fastutil.ints.IntArrayList; +import it.unimi.dsi.fastutil.ints.IntList; import lombok.Getter; @Getter -@ResourceType(name = "Gacha.json") +@ResourceType(name = "Gacha.json", loadPriority = LoadPriority.LOWEST) public class GachaDef extends BaseDef { private int Id; private int StorageId; @@ -16,6 +21,14 @@ public class GachaDef extends BaseDef { private int GuaranteeTimes; private int GuaranteeTid; private int GuaranteeQty; + private int ATypeGuaranteeTimes; + + private int SpecificTid; + private int SpecificQty; + + private int FirstTenShow; + private String StartTime; + private String EndTime; // Packages private int ATypePkg; @@ -31,6 +44,11 @@ public class GachaDef extends BaseDef { private transient WeightedList packageA; private transient WeightedList packageB; private transient WeightedList packageC; + private transient GachaTypeDef typeData; + private transient IntList allowedCoinItems; + private transient long startTimeSeconds; + private transient long endTimeSeconds; + private transient boolean valid = true; @Override public int getId() { @@ -44,18 +62,110 @@ public class GachaDef extends BaseDef { public GachaStorageDef getStorageData() { return GameData.getGachaStorageDataTable().get(this.getStorageId()); } - + + public GachaTypeDef getTypeData() { + if (this.typeData != null) { + return this.typeData; + } + + return GameData.getGachaTypeDataTable().get(this.getGachaType()); + } + + public boolean containsAllowedCoinItem(int itemId) { + return this.allowedCoinItems != null && this.allowedCoinItems.contains(itemId); + } + + public boolean isActiveAt(long now) { + return this.valid && now >= this.startTimeSeconds && now <= this.endTimeSeconds; + } + + public int getDisplayAUpGuaranteeTimes() { + if (this.ATypeGuaranteeTimes > 0) { + return this.ATypeGuaranteeTimes; + } + + var storage = this.getStorageData(); + return storage != null ? storage.getAUpGuaranteeTimes() : 0; + } + + private boolean hasValidPackage(int packageId) { + if (packageId <= 0) { + return false; + } + + var pkg = GachaPkgDef.getPackageById(packageId); + return pkg != null && pkg.size() > 0; + } + + private boolean isSpinConfigValid() { + boolean hasValidA = (this.ATypePkg > 0 && hasValidPackage(this.ATypePkg)) + || (this.ATypeUpPkg > 0 && hasValidPackage(this.ATypeUpPkg)); + boolean hasValidB = (this.BTypePkg > 0 && hasValidPackage(this.BTypePkg)) + || (this.BGuaranteePkg > 0 && hasValidPackage(this.BGuaranteePkg)) + || (this.BTypeUpPkg > 0 && hasValidPackage(this.BTypeUpPkg)); + boolean hasValidC = this.CTypePkg > 0 && hasValidPackage(this.CTypePkg); + + return this.packageA != null && this.packageA.size() > 0 + && this.packageB != null && this.packageB.size() > 0 + && this.packageC != null && this.packageC.size() > 0 + && hasValidA && hasValidB && hasValidC; + } + + private void markInvalid(String message) { + this.valid = false; + this.startTimeSeconds = 1L; + this.endTimeSeconds = 0L; + Nebula.getLogger().error("Skip invalid gacha config for banner {}: {}", this.getId(), message); + } + @Override public void onLoad() { + this.valid = true; + this.startTimeSeconds = 0L; + this.endTimeSeconds = Long.MAX_VALUE; + + this.packageA = new WeightedList<>(); + this.packageB = new WeightedList<>(); + this.packageC = new WeightedList<>(); + this.allowedCoinItems = new IntArrayList(); + + this.typeData = this.getTypeData(); + if (this.typeData == null) { + markInvalid("invalid GachaType " + this.getGachaType()); + return; + } + if (this.typeData.getCoinItem() != null) { + this.allowedCoinItems.addAll(this.typeData.getCoinItem()); + } + + if (this.StartTime != null && !this.StartTime.isBlank()) { + this.startTimeSeconds = Utils.dateToMilliseconds(this.StartTime) / 1000; + } + if (this.EndTime != null && !this.EndTime.isBlank()) { + this.endTimeSeconds = Utils.dateToMilliseconds(this.EndTime) / 1000; + } + // Get storage var storage = this.getStorageData(); - + if (storage == null) { + markInvalid("invalid StorageId " + this.getStorageId()); + return; + } + + boolean isNewbieBanner = GameData.getGachaNewbieDataTable().containsKey(this.getId()); + if (!isNewbieBanner) { + // DefaultId can be a dedicated ticket item that is not listed in CoinItem. + // Only the fallback currency (CostId) is required to be in the type whitelist. + if (!this.containsAllowedCoinItem(storage.getCostId())) { + markInvalid("CostId not allowed by GachaType: " + storage.getCostId()); + return; + } + } + // Package A - this.packageA = new WeightedList(); - if (this.ATypePkg > 0) { packageA.add( - 10000 - storage.getATypeUpProb(), + 10000 - storage.getATypeUpProb(), new GachaPackage(GachaPackageType.A, this.ATypePkg) ); } @@ -65,38 +175,38 @@ public class GachaDef extends BaseDef { new GachaPackage(GachaPackageType.A_UP, this.ATypeUpPkg) ); } - + // Package B - this.packageB = new WeightedList(); - if (this.BTypePkg > 0) { packageB.add( - storage.getBTypeProb(), + storage.getBTypeProb(), new GachaPackage(GachaPackageType.B, this.BTypePkg) ); } if (this.BGuaranteePkg > 0) { packageB.add( - storage.getBTypeGuaranteeProb(), + storage.getBTypeGuaranteeProb(), new GachaPackage(GachaPackageType.B, this.BGuaranteePkg) ); - } + } if (this.BTypeUpPkg > 0) { packageB.add( storage.getBTypeUpProb(), new GachaPackage(GachaPackageType.B_UP, this.BTypeUpPkg) ); } - + // Package C - this.packageC = new WeightedList(); - if (this.CTypePkg > 0) { packageC.add( 10000, new GachaPackage(GachaPackageType.C, this.CTypePkg) ); } + + if (!isSpinConfigValid()) { + markInvalid("spin package/probability composition is invalid"); + } } @Getter diff --git a/src/main/java/emu/nebula/data/resources/GachaNewbieDef.java b/src/main/java/emu/nebula/data/resources/GachaNewbieDef.java new file mode 100644 index 0000000..6d7ab88 --- /dev/null +++ b/src/main/java/emu/nebula/data/resources/GachaNewbieDef.java @@ -0,0 +1,19 @@ +package emu.nebula.data.resources; + +import emu.nebula.data.BaseDef; +import emu.nebula.data.ResourceType; +import lombok.Getter; + +@Getter +@ResourceType(name = "GachaNewbie.json") +public class GachaNewbieDef extends BaseDef { + private int Id; + private int SpinCount; + private int SaveCount; + + @Override + public int getId() { + return this.Id; + } + +} diff --git a/src/main/java/emu/nebula/data/resources/GachaStorageDef.java b/src/main/java/emu/nebula/data/resources/GachaStorageDef.java index 422435d..284702f 100644 --- a/src/main/java/emu/nebula/data/resources/GachaStorageDef.java +++ b/src/main/java/emu/nebula/data/resources/GachaStorageDef.java @@ -3,6 +3,7 @@ package emu.nebula.data.resources; import emu.nebula.data.BaseDef; import emu.nebula.data.ResourceType; import emu.nebula.data.ResourceType.LoadPriority; +import emu.nebula.game.inventory.ItemParamMap; import lombok.Getter; @Getter @@ -15,13 +16,29 @@ public class GachaStorageDef extends BaseDef { private int CostId; private int CostQty; + private int ATypeGroup; + private int AUpGuaranteeTimes; private int ATypeUpProb; + private int ATypeUpShowProb; + private int BTypeProb; + private int BGuaranteeTimes; private int BTypeUpProb; + private int BTypeUpShowProb; private int BTypeGuaranteeProb; + private String GiveItems; + private transient ItemParamMap giveItemsMap = new ItemParamMap(); + @Override public int getId() { return Id; } + + @Override + public void onLoad() { + if (this.GiveItems != null && !this.GiveItems.isEmpty()) { + this.giveItemsMap = ItemParamMap.fromJsonString(this.GiveItems); + } + } } diff --git a/src/main/java/emu/nebula/data/resources/GachaTypeDef.java b/src/main/java/emu/nebula/data/resources/GachaTypeDef.java new file mode 100644 index 0000000..481cdad --- /dev/null +++ b/src/main/java/emu/nebula/data/resources/GachaTypeDef.java @@ -0,0 +1,30 @@ +package emu.nebula.data.resources; + +import emu.nebula.data.BaseDef; +import emu.nebula.data.ResourceType; +import it.unimi.dsi.fastutil.ints.IntArrayList; +import lombok.Getter; + +@Getter +@ResourceType(name = "GachaType.json") +public class GachaTypeDef extends BaseDef { + private int Id; + private IntArrayList CoinItem; + + @Override + public int getId() { + return this.Id; + } + + @Override + public void onLoad() { + if (this.CoinItem == null) { + this.CoinItem = new IntArrayList(); + } + } + + public boolean containsCoinItem(int itemId) { + return this.CoinItem != null && this.CoinItem.contains(itemId); + } + +} diff --git a/src/main/java/emu/nebula/database/DatabaseManager.java b/src/main/java/emu/nebula/database/DatabaseManager.java index cd382b6..7b0d8e4 100644 --- a/src/main/java/emu/nebula/database/DatabaseManager.java +++ b/src/main/java/emu/nebula/database/DatabaseManager.java @@ -1,6 +1,8 @@ package emu.nebula.database; +import java.util.ArrayList; import java.util.List; +import java.util.Map; import java.util.stream.Stream; import emu.nebula.Config.DatabaseInfo; @@ -30,6 +32,7 @@ import dev.morphia.mapping.MapperOptions; import dev.morphia.query.FindOptions; import dev.morphia.query.Sort; import dev.morphia.query.filters.Filters; +import dev.morphia.query.updates.UpdateOperator; import dev.morphia.query.updates.UpdateOperators; import lombok.Getter; @@ -110,7 +113,7 @@ public final class DatabaseManager { ensureIndexes(); // Done - Nebula.getLogger().info("Connected to the MongoDB database at " + connectionString); + Nebula.getLogger().info("Connected to the MongoDB database at {}", connectionString); } public MongoDatabase getDatabase() { @@ -177,6 +180,30 @@ public final class DatabaseManager { .toList(); } + public List getSortedObjects( + Class cls, + String filter, + int value, + String filter2, + int value2, + String minFilter, + long minValue, + String sortBy, + int limit + ) { + var options = new FindOptions() + .sort(Sort.descending(sortBy)) + .limit(limit); + + return getDatastore() + .find(cls) + .filter(Filters.eq(filter, value)) + .filter(Filters.eq(filter2, value2)) + .filter(Filters.gte(minFilter, minValue)) + .iterator(options) + .toList(); + } + public void save(T obj) { getDatastore().save(obj, INSERT_OPTIONS); } @@ -238,6 +265,20 @@ public final class DatabaseManager { .filter(Filters.eq("_id", uid)) .update(opt, UpdateOperators.addToSet(field, item)); } + + public void update(Object obj, int uid, Map fields) { + if (fields == null || fields.isEmpty()) { + return; + } + + var operators = new ArrayList(); + fields.forEach((field, value) -> operators.add(UpdateOperators.set(field, value))); + + var opt = new UpdateOptions().upsert(false); + getDatastore().find(obj.getClass()) + .filter(Filters.eq("_id", uid)) + .update(opt, operators.toArray(UpdateOperator[]::new)); + } // Database counter diff --git a/src/main/java/emu/nebula/game/gacha/GachaBannerInfo.java b/src/main/java/emu/nebula/game/gacha/GachaBannerInfo.java index 19081c2..8e37cad 100644 --- a/src/main/java/emu/nebula/game/gacha/GachaBannerInfo.java +++ b/src/main/java/emu/nebula/game/gacha/GachaBannerInfo.java @@ -2,23 +2,21 @@ package emu.nebula.game.gacha; import dev.morphia.annotations.Entity; +import emu.nebula.data.GameData; import emu.nebula.data.resources.GachaDef; -import emu.nebula.data.resources.GachaDef.GachaPackage; -import emu.nebula.data.resources.GachaPkgDef; import emu.nebula.proto.GachaInformation.GachaInfo; -import emu.nebula.util.Utils; import lombok.Getter; +import lombok.Setter; @Getter +@Setter @Entity(useDiscriminator = false) public class GachaBannerInfo { private int id; private int total; - private int missTimesA; - private int missTimesUpA; - private int missTimesB; + private boolean usedFirstTen; private boolean usedGuarantee; @Deprecated //Morphia only @@ -29,83 +27,54 @@ public class GachaBannerInfo { public GachaBannerInfo(GachaDef data) { this.id = data.getId(); } - - public void setUsedGuarantee(boolean value) { - this.usedGuarantee = value; - } - - public int doPull(GachaDef data) { - // Pull chances - int chanceA = 20; // 2% - int chanceB = 100; // 8% - - // 4 star pity - if (this.missTimesB >= 9) { - chanceB = 1000; - } - - // 5 star pity - if (this.missTimesA >= 159) { - chanceA = 1000; - chanceB = 0; - } - - // Add miss times - this.missTimesB++; - this.missTimesA++; - //this.missTimesUpA++; - - // Get random - int random = Utils.randomRange(1, 1000); - GachaPackage gp = null; - - if (random <= chanceA) { - // Reset pity - this.missTimesA = 0; - - // Get A package - gp = data.getPackageA().next(); - } else if (random <= chanceB) { - // Add miss times - this.missTimesB = 0; - - // Get B package - gp = data.getPackageB().next(); - } else { - // Get C package - gp = data.getPackageC().next(); - } - - // Sanity check - if (gp == null) { - return 0; - } - - // Get package - var pkg = GachaPkgDef.getPackageById(gp.getId()); - if (pkg == null) { - return 0; - } - - // Add total pulls - this.total++; - - // Get random id - return pkg.next(); - } - - // Proto - public GachaInfo toProto() { - var proto = GachaInfo.newInstance() + public GachaBannerDraft copyForSpin() { + return new GachaBannerDraft(this.total, this.usedFirstTen, this.usedGuarantee); + } + + public void overwriteFrom(GachaBannerDraft draft) { + if (draft == null) { + return; + } + + this.total = draft.total(); + this.usedFirstTen = draft.usedFirstTen(); + this.usedGuarantee = draft.usedGuarantee(); + } + + public GachaInfo toProto(GachaPityState pityState) { + int aupGuaranteeTimes = 0; + var showFirstTenBonusHintText = false; + var gachaData = GameData.getGachaDataTable().get(this.getId()); + var storageData = gachaData != null ? gachaData.getStorageData() : null; + if (storageData != null) { + aupGuaranteeTimes = gachaData.getDisplayAUpGuaranteeTimes(); + showFirstTenBonusHintText = !storageData.getGiveItemsMap().isEmpty() && !this.isUsedFirstTen(); + } + + int missTimesUpA = 0; + int missTimesA = 0; + if (pityState != null) { + missTimesUpA = pityState.getMissTimesUpA(); + missTimesA = pityState.getMissTimesA(); + } + + return GachaInfo.newInstance() .setId(this.getId()) .setGachaTotalTimes(this.getTotal()) .setTotalTimes(this.getTotal()) - .setAupMissTimes(this.getMissTimesA()) - .setAMissTimes(this.getMissTimesA()) - .setReveFirstTenReward(true) + .setAupMissTimes(missTimesUpA) + .setAMissTimes(missTimesA) + .setAupGuaranteeTimes(aupGuaranteeTimes) + .setReveFirstTenReward(!showFirstTenBonusHintText) .setRecvGuaranteeReward(this.isUsedGuarantee()); - - return proto; } + + public record GachaBannerDraft( + int total, + boolean usedFirstTen, + boolean usedGuarantee + ) { + } + } diff --git a/src/main/java/emu/nebula/game/gacha/GachaDataMigration.java b/src/main/java/emu/nebula/game/gacha/GachaDataMigration.java new file mode 100644 index 0000000..23db4e7 --- /dev/null +++ b/src/main/java/emu/nebula/game/gacha/GachaDataMigration.java @@ -0,0 +1,205 @@ +package emu.nebula.game.gacha; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import emu.nebula.data.GameData; +import emu.nebula.util.JsonUtils; +import org.bson.Document; + +import com.mongodb.client.model.Filters; +import com.mongodb.client.model.UpdateOneModel; +import com.mongodb.client.model.UpdateOptions; +import com.mongodb.client.model.Updates; + +import emu.nebula.Nebula; + +public final class GachaDataMigration { + private static final String TABLE_GACHA = "gacha"; + private static final String TABLE_GACHA_HISTORY = "gacha_history"; + + private static final String FIELD_ID = "_id"; + private static final String FIELD_PITY_STATES = "pityStates"; + private static final String FIELD_HISTORIES = "histories"; + + private static final String FIELD_PLAYER_UID = "playerUid"; + private static final String FIELD_TYPE = "type"; + private static final String FIELD_GID = "gid"; + private static final String FIELD_TIME = "time"; + private static final String FIELD_IDS = "ids"; + + private static final String FIELD_MISS_TIMES_A = "missTimesA"; + private static final String FIELD_MISS_TIMES_UP_A = "missTimesUpA"; + private static final String FIELD_MISS_TIMES_B = "missTimesB"; + + @SuppressWarnings({"MismatchedQueryAndUpdateOfCollection"}) + private static final class LegacyPlayerGachaDoc { + int _id; + Map banners; + Map> histories; + } + + private static final class LegacyBannerState { + int id; + int missTimesA; + int missTimesUpA; + int missTimesB; + } + + private static final class LegacyHistoryRow { + int type; + int gid; + long time; + List ids = new ArrayList<>(); + } + + private static Document derivePityStatesFromBanners(Map banners) { + if (banners == null || banners.isEmpty()) { + return null; + } + + var pityStates = new Document(); + for (var entry : banners.entrySet()) { + var bannerState = entry.getValue(); + if (bannerState == null) { + continue; + } + + // Trust inner banner id; map key is treated as legacy container key. + int bannerId = bannerState.id; + if (bannerId <= 0) { + continue; + } + + var data = GameData.getGachaDataTable().get(bannerId); + if (data == null) { + continue; + } + + // New pity state key is storageId, so multiple banners may merge here. + String storageKey = String.valueOf(data.getStorageId()); + var existing = pityStates.get(storageKey, Document.class); + int existingA = existing != null ? existing.getInteger(FIELD_MISS_TIMES_A, 0) : 0; + int existingUpA = existing != null ? existing.getInteger(FIELD_MISS_TIMES_UP_A, 0) : 0; + int existingB = existing != null ? existing.getInteger(FIELD_MISS_TIMES_B, 0) : 0; + + // Keep the max counters per storageId to preserve the strongest pity progress. + int missTimesA = Math.max(existingA, bannerState.missTimesA); + int missTimesUpA = Math.max(existingUpA, bannerState.missTimesUpA); + int missTimesB = Math.max(existingB, bannerState.missTimesB); + + pityStates.put(storageKey, new Document(FIELD_MISS_TIMES_A, missTimesA) + .append(FIELD_MISS_TIMES_UP_A, missTimesUpA) + .append(FIELD_MISS_TIMES_B, missTimesB)); + } + + return pityStates.isEmpty() ? null : pityStates; + } + + // One-time startup migration for legacy gacha data. + // - Derive pityStates from banner-embedded pity counters. + // - Move embedded histories into a single table: gacha_history. + public static void run() { + // Collections: source player gacha state + target split history table. + var database = Nebula.getGameDatabase().getDatabase(); + var gachaCollection = database.getCollection(TABLE_GACHA); + var historyCollection = database.getCollection(TABLE_GACHA_HISTORY); + + // Query index for history browsing by player and pool type. + historyCollection.createIndex(new Document(FIELD_PLAYER_UID, 1).append(FIELD_TYPE, 1).append(FIELD_TIME, -1)); + + long scanned = 0; + long updated = 0; + long migratedHistoryAttempts = 0; + long migratedHistoryInserts = 0; + + var migrationFilter = Filters.or( + Filters.exists(FIELD_HISTORIES), + Filters.and(Filters.exists("banners"), Filters.not(Filters.exists(FIELD_PITY_STATES))) + ); + + // Process only players that still have legacy fields to migrate. + for (var gachaDoc : gachaCollection.find(migrationFilter)) { + scanned++; + + // Step 1: decode raw document into typed legacy DTO. + var legacy = JsonUtils.decode(gachaDoc.toJson(), LegacyPlayerGachaDoc.class); + if (legacy == null || legacy._id <= 0) { + continue; + } + + int uid = legacy._id; + var ops = new ArrayList(); + + Object pityStates = gachaDoc.get(FIELD_PITY_STATES); + if (pityStates == null) { + // Step 2: derive storage-level pity states from legacy banners. + var derivedPityStates = derivePityStatesFromBanners(legacy.banners); + if (derivedPityStates != null) { + ops.add(Updates.set(FIELD_PITY_STATES, derivedPityStates)); + } + } + + if (legacy.histories != null && !legacy.histories.isEmpty()) { + var upserts = new ArrayList>(); + for (var typeEntry : legacy.histories.entrySet()) { + var historyRows = typeEntry.getValue(); + if (historyRows == null) { + continue; + } + + for (var row : historyRows) { + if (row == null || row.ids == null) { + continue; + } + int type = row.type; + var filter = Filters.and( + Filters.eq(FIELD_PLAYER_UID, uid), + Filters.eq(FIELD_TYPE, type), + Filters.eq(FIELD_GID, row.gid), + Filters.eq(FIELD_TIME, row.time), + Filters.eq(FIELD_IDS, row.ids) + ); + + // Step 3: split one legacy history row into gacha_history (idempotent upsert). + upserts.add(new UpdateOneModel<>( + filter, + Updates.combine( + Updates.setOnInsert(FIELD_PLAYER_UID, uid), + Updates.setOnInsert(FIELD_TYPE, type), + Updates.setOnInsert(FIELD_GID, row.gid), + Updates.setOnInsert(FIELD_TIME, row.time), + Updates.setOnInsert(FIELD_IDS, row.ids) + ), + new UpdateOptions().upsert(true) + )); + } + } + + if (!upserts.isEmpty()) { + var result = historyCollection.bulkWrite(upserts); + migratedHistoryAttempts += upserts.size(); + migratedHistoryInserts += result.getUpserts().size(); + } + + // Step 4: remove embedded legacy histories after split migration. + ops.add(Updates.unset(FIELD_HISTORIES)); + } + + if (!ops.isEmpty()) { + // Step 5: commit player-level migration changes. + gachaCollection.updateOne(Filters.eq(FIELD_ID, uid), Updates.combine(ops)); + updated++; + } + } + + Nebula.getLogger().info( + "Gacha migration completed. scanned={}, updated={}, historyAttempts={}, historyInserts={}", + scanned, + updated, + migratedHistoryAttempts, + migratedHistoryInserts + ); + } +} diff --git a/src/main/java/emu/nebula/game/gacha/GachaHistoryLog.java b/src/main/java/emu/nebula/game/gacha/GachaHistoryLog.java index 4268ea1..ef2e1a7 100644 --- a/src/main/java/emu/nebula/game/gacha/GachaHistoryLog.java +++ b/src/main/java/emu/nebula/game/gacha/GachaHistoryLog.java @@ -1,17 +1,32 @@ package emu.nebula.game.gacha; import dev.morphia.annotations.Entity; +import dev.morphia.annotations.Id; +import dev.morphia.annotations.Indexed; import emu.nebula.Nebula; import emu.nebula.proto.GachaHistoriesOuterClass.GachaHistory; import it.unimi.dsi.fastutil.ints.IntList; import lombok.Getter; +import org.bson.types.ObjectId; @Getter -@Entity(value = "banner_info", useDiscriminator = false) +@Entity(value = "gacha_history", useDiscriminator = false) public class GachaHistoryLog { + + @Id + private ObjectId id; + + @Indexed + private int playerUid; + + @Indexed private int type; + private int gid; + + @Indexed private long time; + private IntList ids; @Deprecated // Morphia only @@ -19,15 +34,15 @@ public class GachaHistoryLog { } - public GachaHistoryLog(int type, int gachaId, IntList results) { + public GachaHistoryLog(int playerUid, int type, int gachaId, IntList results) { + this.playerUid = playerUid; this.type = type; this.gid = gachaId; this.time = Nebula.getCurrentServerTime(); this.ids = results; } - + // Proto - public GachaHistory toProto() { var proto = GachaHistory.newInstance() .setGid(this.getGid()) diff --git a/src/main/java/emu/nebula/game/gacha/GachaManager.java b/src/main/java/emu/nebula/game/gacha/GachaManager.java index e3cdc81..2b2c363 100644 --- a/src/main/java/emu/nebula/game/gacha/GachaManager.java +++ b/src/main/java/emu/nebula/game/gacha/GachaManager.java @@ -1,32 +1,37 @@ package emu.nebula.game.gacha; -import java.util.ArrayList; -import java.util.Collection; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - import dev.morphia.annotations.Entity; import dev.morphia.annotations.Id; import emu.nebula.Nebula; -import emu.nebula.data.GameData; import emu.nebula.data.resources.GachaDef; +import emu.nebula.data.resources.GachaNewbieDef; import emu.nebula.database.GameDatabaseObject; import emu.nebula.game.player.Player; -import emu.nebula.game.player.PlayerChangeInfo; import emu.nebula.game.player.PlayerManager; - +import it.unimi.dsi.fastutil.ints.IntList; import lombok.Getter; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +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 banners; - private Map> histories; - + + private Map banners = new HashMap<>(); + private Map pityStates = new HashMap<>(); + private Map newbieStates = new HashMap<>(); + private transient Set lockedNewbieObtainIds = new HashSet<>(); + @Deprecated // Morphia only public GachaManager() { @@ -36,103 +41,134 @@ public class GachaManager extends PlayerManager implements GameDatabaseObject { this(); this.setPlayer(player); this.uid = player.getUid(); - - this.banners = new HashMap<>(); - this.histories = new HashMap<>(); - + this.save(); } - - public synchronized Collection getBannerInfos() { - return this.banners.values(); + + public synchronized NewbieObtainLock lockNewbieObtain(int newbieId) { + if (this.lockedNewbieObtainIds.contains(newbieId)) { + return null; + } + + this.lockedNewbieObtainIds.add(newbieId); + return new NewbieObtainLockHandle(newbieId); } - + + public synchronized boolean isNewbieObtainLocked(int newbieId) { + return this.lockedNewbieObtainIds.contains(newbieId); + } + + private synchronized void unlockNewbieObtainInternal(int newbieId) { + this.lockedNewbieObtainIds.remove(newbieId); + } + public synchronized GachaBannerInfo getBannerInfo(GachaDef gachaData) { return this.banners.computeIfAbsent( - gachaData.getId(), - i -> new GachaBannerInfo(gachaData) + gachaData.getId(), + i -> new GachaBannerInfo(gachaData) ); } - + + public synchronized GachaPityState getPityState(int storageId) { + return this.pityStates.computeIfAbsent(storageId, i -> new GachaPityState()); + } + + public synchronized GachaBannerInfo findBannerInfo(int bannerId) { + return this.banners.get(bannerId); + } + + public synchronized GachaPityState findPityState(int storageId) { + return this.pityStates.get(storageId); + } + + public synchronized void saveSpinState(GachaBannerInfo info, int storageId, int gachaId, IntList results) { + this.pityStates.computeIfAbsent(storageId, i -> new GachaPityState()); + + var updates = new HashMap(); + updates.put(PATH_BANNERS + info.getId(), info); + updates.put(PATH_PITY_STATES + storageId, this.pityStates.get(storageId)); + Nebula.getGameDatabase().update(this, this.getPlayerUid(), updates); + + var log = new GachaHistoryLog(this.getPlayerUid(), storageId, gachaId, results); + Exception lastError = null; + for (int attempt = 1; attempt <= HISTORY_SAVE_RETRIES + 1; attempt++) { + try { + Nebula.getGameDatabase().save(log); + return; + } catch (Exception e) { + lastError = e; + } + } + + Nebula.getLogger().warn( + "Failed to persist gacha history after retries. uid={}, type={}, gid={}, time={}", + log.getPlayerUid(), + log.getType(), + log.getGid(), + log.getTime(), + lastError + ); + } + + public synchronized NewbieGachaState getOrCreateNewbieState(GachaNewbieDef newbieDef) { + var state = this.newbieStates.get(newbieDef.getId()); + if (state == null) { + state = new NewbieGachaState(newbieDef.getId(), newbieDef.getSpinCount(), newbieDef.getSaveCount()); + this.newbieStates.put(newbieDef.getId(), state); + } + + state.applyConfig(newbieDef.getSaveCount()); + return state; + } + + public synchronized NewbieGachaState findNewbieState(int newbieId) { + return this.newbieStates.get(newbieId); + } + + public synchronized void saveNewbieState(NewbieGachaState state) { + this.newbieStates.put(state.getId(), state); + + Nebula.getGameDatabase().update( + this, + this.getPlayerUid(), + PATH_NEWBIE_STATES + state.getId(), + state + ); + } + public void saveBanner(GachaBannerInfo info) { Nebula.getGameDatabase().update( - this, - this.getPlayerUid(), - "banners." + info.getId(), - info + this, + this.getPlayerUid(), + PATH_BANNERS + info.getId(), + info ); } - - public PlayerChangeInfo recvGuarantee(int id) { - // Get banner info - var info = this.banners.get(id); - if (info == null) { - return null; - } - - // Get banner data - var data = GameData.getGachaDataTable().get(id); - if (data == null) { - return null; - } - - // Check if we have enough pulls for a guarantee - if (!data.canGuarantee() || info.getTotal() < data.getGuaranteeTimes()) { - return null; - } - - // Make sure we havent used our guarantee yet - if (info.isUsedGuarantee()) { - return null; - } - - // Set guarantee - info.setUsedGuarantee(true); - // Update to database - this.saveBanner(info); - - // Give player the guaranteed item - return getPlayer().getInventory().addItem(data.getGuaranteeTid(), data.getGuaranteeQty()); - } - - // Histories - - public void addGachaHistory(GachaHistoryLog log) { - // Get history - var list = this.histories.computeIfAbsent( - log.getType(), - i -> new ArrayList<>() - ); - - // Add to history - list.add(log); - - // Limit history - boolean resize = false; - - while (list.size() > 50) { - list.remove(0); - resize = true; + private final class NewbieObtainLockHandle implements NewbieObtainLock { + private final int newbieId; + private boolean closed; + + private NewbieObtainLockHandle(int newbieId) { + this.newbieId = newbieId; } - - // Update to database - if (resize) { - // Replace history logs - Nebula.getGameDatabase().update( - this, - this.getPlayerUid(), - "histories." + log.getType(), - list - ); - } else { - // Add to history list - Nebula.getGameDatabase().addToSet( - this, - this.getPlayerUid(), - "histories." + log.getType(), - log - ); + + @Override + public void close() { + synchronized (GachaManager.this) { + if (this.closed) { + return; + } + + this.closed = true; + unlockNewbieObtainInternal(this.newbieId); + } } } + + public interface NewbieObtainLock extends AutoCloseable { + @Override + void close(); + } + } diff --git a/src/main/java/emu/nebula/game/gacha/GachaMode.java b/src/main/java/emu/nebula/game/gacha/GachaMode.java new file mode 100644 index 0000000..6566644 --- /dev/null +++ b/src/main/java/emu/nebula/game/gacha/GachaMode.java @@ -0,0 +1,38 @@ +package emu.nebula.game.gacha; + +import it.unimi.dsi.fastutil.ints.Int2ObjectMap; +import it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap; +import lombok.Getter; + +@Getter +public enum GachaMode { + + NEWBIE(0, 10), + SINGLE(1, 1), + TEN(2, 10); + + private static final Int2ObjectMap GACHA_MODES = new Int2ObjectOpenHashMap<>(); + + static { + for (var gachaMode : values()) { + GACHA_MODES.put(gachaMode.mode, gachaMode); + } + } + + private final int mode; + private final int amount; + + GachaMode(int mode, int amount) { + this.mode = mode; + this.amount = amount; + } + + public static GachaMode getGachaMode(int mode) { + return GACHA_MODES.get(mode); + } + + public static Integer getAmountByMode(int mode) { + var gachaMode = getGachaMode(mode); + return gachaMode != null ? gachaMode.amount : null; + } +} diff --git a/src/main/java/emu/nebula/game/gacha/GachaModule.java b/src/main/java/emu/nebula/game/gacha/GachaModule.java index 3ab23ef..8b7c1fa 100644 --- a/src/main/java/emu/nebula/game/gacha/GachaModule.java +++ b/src/main/java/emu/nebula/game/gacha/GachaModule.java @@ -1,181 +1,253 @@ package emu.nebula.game.gacha; +import emu.nebula.Nebula; import emu.nebula.data.GameData; +import emu.nebula.data.resources.GachaStorageDef; import emu.nebula.game.GameContext; import emu.nebula.game.GameContextModule; import emu.nebula.game.achievement.AchievementCondition; import emu.nebula.game.inventory.ItemAcquireMap; import emu.nebula.game.inventory.ItemParamMap; -import emu.nebula.game.inventory.ItemType; import emu.nebula.game.player.Player; import emu.nebula.game.player.PlayerChangeInfo; -import emu.nebula.proto.Public.Transform; +import emu.nebula.game.gacha.GachaBannerInfo.GachaBannerDraft; +import emu.nebula.game.gacha.GachaPityState.GachaPityDraft; +import emu.nebula.proto.GachaNewbieInfoOuterClass.GachaNewbieInfo; import it.unimi.dsi.fastutil.ints.IntArrayList; +import java.util.List; + public class GachaModule extends GameContextModule { + private final NewbieGachaModule newbieGachaModule = new NewbieGachaModule(); public GachaModule(GameContext context) { super(context); } - public GachaResult spin(Player player, int bannerId, int mode) { - // Get pull count - int amount = mode == 2 ? 10 : 1; - - // Get banner data + public GachaResult spin(Player player, int bannerId, int amount) { var data = GameData.getGachaDataTable().get(bannerId); - if (data == null) { + if (data == null || !data.isActiveAt(Nebula.getCurrentServerTime())) { return null; } - - var bannerStorage = data.getStorageData(); - if (bannerStorage == null) { - return null; + + var storage = data.getStorageData(); + + var manager = player.getGachaManager(); + synchronized (manager) { + var info = manager.getBannerInfo(data); + var pityState = manager.getPityState(data.getStorageId()); + var spinPlan = prepareSpin(player, data, storage, amount, info, pityState); + if (spinPlan == null) { + return null; + } + + var change = applySpin(player, storage, spinPlan); + persistSpin(manager, info, pityState, data, spinPlan); + + player.trigger(AchievementCondition.GachaTotal, amount); + player.trigger(AchievementCondition.GachaCharacterTotal, spinPlan.rewardPlan().characterCount()); + + return new GachaResult(info, spinPlan.pityDraft(), change, spinPlan.cards()); } - - // Create change info - var change = new PlayerChangeInfo(); - - // Check if we have the materials to gacha TODO - int costQty = player.getInventory().getItemCount(bannerStorage.getDefaultId()); - int costReq = bannerStorage.getDefaultQty() * amount; - - if (costReq > costQty) { - // Not enough materials, check if we can convert - int convertQty = player.getInventory().getResourceCount(bannerStorage.getCostId()); - int convertReq = bannerStorage.getCostQty() * (costReq - costQty); - - // Check if we can buy pulls + } + + public PlayerChangeInfo recvGuarantee(Player player, int bannerId) { + var manager = player.getGachaManager(); + synchronized (manager) { + var info = manager.findBannerInfo(bannerId); + if (info == null) { + return null; + } + + var data = GameData.getGachaDataTable().get(bannerId); + if (data == null) { + return null; + } + + if (!data.isActiveAt(Nebula.getCurrentServerTime())) { + return null; + } + + if (!data.canGuarantee() || info.getTotal() < data.getGuaranteeTimes()) { + return null; + } + + if (info.isUsedGuarantee()) { + return null; + } + info.setUsedGuarantee(true); + + manager.saveBanner(info); + return player.getInventory().addItem(data.getGuaranteeTid(), data.getGuaranteeQty()); + } + } + + public List listNewbieInfos(Player player) { + return this.newbieGachaModule.listInfos(player); + } + + public int[] spinNewbie(Player player, int newbieId) { + return this.newbieGachaModule.spin(player, newbieId); + } + + public boolean saveNewbie(Player player, int newbieId, Integer index) { + return this.newbieGachaModule.save(player, newbieId, index); + } + + public PlayerChangeInfo obtainNewbie(Player player, int newbieId, int index) { + return this.newbieGachaModule.obtain(player, newbieId, index); + } + + private BonusItemsOutcome buildBonusItems(GachaStorageDef storage, + int firstTenMultiplier, + int amount, + GachaBannerDraft snapshot) { + var bonusItems = new ItemParamMap(); + var giveItemsMap = storage.getGiveItemsMap(); + if (giveItemsMap == null || giveItemsMap.isEmpty()) { + return new BonusItemsOutcome(bonusItems, snapshot); + } + + int multiplier = 1; + var draft = snapshot; + if (amount == GachaMode.TEN.getAmount() && !snapshot.usedFirstTen()) { + multiplier = Math.max(1, firstTenMultiplier); + draft = new GachaBannerDraft(snapshot.total(), true, snapshot.usedGuarantee()); + } + + for (var entry : giveItemsMap.entries()) { + bonusItems.add(entry.getIntKey(), entry.getIntValue() * multiplier * amount); + } + return new BonusItemsOutcome(bonusItems, draft); + } + + private record BonusItemsOutcome( + ItemParamMap bonusItems, + GachaBannerDraft bannerDraft + ) { + } + + private SpinPlan prepareSpin(Player player, + emu.nebula.data.resources.GachaDef data, + GachaStorageDef storage, + int amount, + GachaBannerInfo info, + GachaPityState pityState) { + var inventory = player.getInventory(); + int specificConsumeQty = resolveSpecificConsumeQty(inventory, data, amount); + int coveredPullCount = specificConsumeQty > 0 && data.getSpecificQty() > 0 + ? specificConsumeQty / data.getSpecificQty() + : 0; + int remainingDefaultCostReq = storage.getDefaultQty() * Math.max(amount - coveredPullCount, 0); + int defaultQty = inventory.getItemCount(storage.getDefaultId()); + int convertReq = 0; + if (remainingDefaultCostReq > defaultQty) { + int convertQty = inventory.getResourceCount(storage.getCostId()); + convertReq = storage.getCostQty() * (remainingDefaultCostReq - defaultQty); if (convertReq > convertQty) { return null; } - - // Convert to pull currency - player.getInventory().removeItem(bannerStorage.getCostId(), convertReq, change); } - - // Consume pull currency - player.getInventory().removeItem(bannerStorage.getDefaultId(), Math.min(costReq, costQty), change); - - // Get gacha banner info - var info = player.getGachaManager().getBannerInfo(data); - - // Do gacha - var results = new IntArrayList(); - + int consumeDefaultQty = Math.min(remainingDefaultCostReq, defaultQty); + + var pityDraft = pityState.copyForSpin(); + var bannerDraft = info.copyForSpin(); + var bonusOutcome = buildBonusItems(storage, data.getFirstTenShow(), amount, bannerDraft); + bannerDraft = bonusOutcome.bannerDraft(); + + var cards = new IntArrayList(amount); for (int i = 0; i < amount; i++) { - int id = info.doPull(data); - if (id <= 0) continue; - - results.add(id); - } - - // Setup variables - var acquireItems = new ItemAcquireMap(player, results); - var transformItemsSrc = new ItemParamMap(); - var transformItemsDst = new ItemParamMap(); - var bonusItems = new ItemParamMap(); - - // Character count (for achievements) - int characters = 0; - - // Add for player - for (var entry : acquireItems.getItems().int2ObjectEntrySet()) { - // Get ids and aquire params - int id = entry.getIntKey(); - var acquire = entry.getValue(); - - // Add to player - if (acquire.getType() == ItemType.Char) { - // Get add amount - int count = acquire.getCount(); - - // Add char to player - if (acquire.getBegin() == 0) { - player.getInventory().addItem(id, 1, change); - count--; - } - - // Talent material - if (count > 0) { - var characterData = GameData.getCharacterDataTable().get(id); - if (characterData == null) continue; - - transformItemsSrc.add(id, count); - transformItemsDst.add(characterData.getFragmentsId(), characterData.getTransformQty() * count); - transformItemsDst.add(24, 40 * count); // Expert permits - } - - // Add to count - characters += acquire.getCount(); - } else if (acquire.getType() == ItemType.Disc) { - // Get add amount - int begin = acquire.getBegin(); - int count = acquire.getCount(); - - // Add disc to player - if (begin == 0) { - player.getInventory().addItem(id, 1, change); - count--; - begin++; - } - - // Talent material - int maxTransformCount = Math.max(6 - begin, 0); - int transformCount = Math.min(count, maxTransformCount); - int extraCount = count - maxTransformCount; - - // Transform - if (transformCount > 0) { - var discData = GameData.getDiscDataTable().get(id); - if (discData == null) continue; - - // Star material - transformItemsSrc.add(id, transformCount); - transformItemsDst.add(discData.getTransformItemId(), transformCount); - } else if (extraCount > 0) { - // Permit - transformItemsSrc.add(id, extraCount); - transformItemsDst.add(23, 100 * extraCount); - } - - // Add Travel permits - bonusItems.add(23, 100 * acquire.getCount()); - } else { - // Should never happen - bonusItems.add(id, acquire.getCount()); + var pullOutcome = GachaRollEngine.pull(data, pityDraft); + if (pullOutcome == null || pullOutcome.itemId() <= 0) { + Nebula.getLogger().warn("Gacha roll produced invalid item. uid={}, bannerId={}", player.getUid(), data.getId()); + return null; } - - // Add gold discs - bonusItems.add(602, 30 * acquire.getCount()); + + pityDraft = pullOutcome.pityDraft(); + cards.add(pullOutcome.itemId()); } - - // Add transform items to extra items - bonusItems.add(transformItemsDst); // Add transform items - - // Add extra items - player.getInventory().addItems(bonusItems, change); - - // Add acquire/transform protos - change.add(acquireItems.toProto()); - - var transform = Transform.newInstance(); - transformItemsSrc.toItemTemplateStream().forEach(transform::addSrc); - transformItemsDst.toItemTemplateStream().forEach(transform::addDst); - change.add(transform); - - // Save banner info to database - player.getGachaManager().saveBanner(info); - - // Add history - var log = new GachaHistoryLog(data.getStorageId(), data.getId(), results); - player.getGachaManager().addGachaHistory(log); - - // Trigger achievements - player.trigger(AchievementCondition.GachaTotal, amount); - player.trigger(AchievementCondition.GachaCharacterTotal, characters); - - // Complete - return new GachaResult(info, change, results); + + bannerDraft = new GachaBannerDraft( + bannerDraft.total() + amount, + bannerDraft.usedFirstTen(), + bannerDraft.usedGuarantee() + ); + + if (!GachaRewardResolver.isResolvable(cards)) { + Nebula.getLogger().warn("Gacha roll contains unresolved rewards. uid={}, bannerId={}", player.getUid(), data.getId()); + return null; + } + + var acquireItems = new ItemAcquireMap(player, cards); + var rewardPlan = GachaRewardResolver.resolve(acquireItems, bonusOutcome.bonusItems()); + if (rewardPlan == null) { + return null; + } + + return new SpinPlan( + bannerDraft, + pityDraft, + cards, + rewardPlan, + new CostPlan(data.getSpecificTid(), specificConsumeQty, convertReq, consumeDefaultQty) + ); } + + private PlayerChangeInfo applySpin(Player player, GachaStorageDef storage, SpinPlan spinPlan) { + var change = new PlayerChangeInfo(); + var inventory = player.getInventory(); + + if (spinPlan.costPlan().consumeSpecificQty() > 0) { + inventory.removeItem(spinPlan.costPlan().specificItemId(), spinPlan.costPlan().consumeSpecificQty(), change); + } + if (spinPlan.costPlan().convertReq() > 0) { + inventory.removeItem(storage.getCostId(), spinPlan.costPlan().convertReq(), change); + } + if (spinPlan.costPlan().consumeDefaultQty() > 0) { + inventory.removeItem(storage.getDefaultId(), spinPlan.costPlan().consumeDefaultQty(), change); + } + + GachaRewardResolver.apply(player, spinPlan.rewardPlan(), change); + return change; + } + + private void persistSpin(GachaManager manager, + GachaBannerInfo info, + GachaPityState pityState, + emu.nebula.data.resources.GachaDef data, + SpinPlan spinPlan) { + info.overwriteFrom(spinPlan.bannerDraft()); + pityState.overwriteFrom(spinPlan.pityDraft()); + manager.saveSpinState(info, data.getStorageId(), data.getId(), spinPlan.cards()); + } + + private int resolveSpecificConsumeQty(emu.nebula.game.inventory.Inventory inventory, + emu.nebula.data.resources.GachaDef data, + int amount) { + if (data.getSpecificTid() <= 0 || data.getSpecificQty() <= 0) { + return 0; + } + + int specificReq = data.getSpecificQty() * amount; + int specificQty = inventory.getItemCount(data.getSpecificTid()); + return Math.min(specificReq, specificQty); + } + + private record CostPlan( + int specificItemId, + int consumeSpecificQty, + int convertReq, + int consumeDefaultQty + ) { + } + + private record SpinPlan( + GachaBannerDraft bannerDraft, + GachaPityDraft pityDraft, + IntArrayList cards, + GachaRewardResolver.RewardPlan rewardPlan, + CostPlan costPlan + ) { + } + } diff --git a/src/main/java/emu/nebula/game/gacha/GachaPityState.java b/src/main/java/emu/nebula/game/gacha/GachaPityState.java new file mode 100644 index 0000000..db1a61e --- /dev/null +++ b/src/main/java/emu/nebula/game/gacha/GachaPityState.java @@ -0,0 +1,40 @@ +package emu.nebula.game.gacha; + +import dev.morphia.annotations.Entity; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Entity(useDiscriminator = false) +public class GachaPityState { + + private int missTimesA; + private int missTimesUpA; + private int missTimesB; + private boolean bGuaranteeDebt; + + public GachaPityDraft copyForSpin() { + return new GachaPityDraft(this.missTimesA, this.missTimesUpA, this.missTimesB, this.bGuaranteeDebt); + } + + public void overwriteFrom(GachaPityDraft draft) { + if (draft == null) { + return; + } + + this.missTimesA = draft.missTimesA(); + this.missTimesUpA = draft.missTimesUpA(); + this.missTimesB = draft.missTimesB(); + this.bGuaranteeDebt = draft.bGuaranteeDebt(); + } + + public record GachaPityDraft( + int missTimesA, + int missTimesUpA, + int missTimesB, + boolean bGuaranteeDebt + ) { + } + +} diff --git a/src/main/java/emu/nebula/game/gacha/GachaResult.java b/src/main/java/emu/nebula/game/gacha/GachaResult.java index a0fddc2..74bb561 100644 --- a/src/main/java/emu/nebula/game/gacha/GachaResult.java +++ b/src/main/java/emu/nebula/game/gacha/GachaResult.java @@ -1,19 +1,48 @@ package emu.nebula.game.gacha; +import emu.nebula.Nebula; +import emu.nebula.data.resources.GachaDef; import emu.nebula.game.player.PlayerChangeInfo; +import emu.nebula.game.gacha.GachaPityState.GachaPityDraft; +import emu.nebula.proto.GachaSpin.GachaCard; +import emu.nebula.proto.GachaSpin.GachaSpinResp; +import emu.nebula.proto.Public.ItemTpl; import it.unimi.dsi.fastutil.ints.IntList; import lombok.Getter; @Getter public class GachaResult { private GachaBannerInfo info; + private GachaPityDraft pityState; private PlayerChangeInfo change; private IntList cards; - public GachaResult(GachaBannerInfo info, PlayerChangeInfo change, IntList cards) { + public GachaResult(GachaBannerInfo info, GachaPityDraft pityState, PlayerChangeInfo change, IntList cards) { this.info = info; + this.pityState = pityState; this.change = change; this.cards = cards; } - + + public GachaSpinResp toSpinResp(GachaDef gachaData) { + int aupGuaranteeTimes = gachaData != null ? gachaData.getDisplayAUpGuaranteeTimes() : 0; + + var rsp = GachaSpinResp.newInstance() + .setTime(Nebula.getCurrentServerTime()) + .setAMissTimes(this.pityState.missTimesA()) + .setAupMissTimes(this.pityState.missTimesUpA()) + .setTotalTimes(this.info.getTotal()) + .setGachaTotalTimes(this.info.getTotal()) + .setAupGuaranteeTimes(aupGuaranteeTimes) + .setChange(this.change.toProto()); + + for (int id : this.cards) { + var card = GachaCard.newInstance() + .setCard(ItemTpl.newInstance().setTid(id).setQty(1)); + rsp.addCards(card); + } + + return rsp; + } + } diff --git a/src/main/java/emu/nebula/game/gacha/GachaRewardResolver.java b/src/main/java/emu/nebula/game/gacha/GachaRewardResolver.java new file mode 100644 index 0000000..a7c035b --- /dev/null +++ b/src/main/java/emu/nebula/game/gacha/GachaRewardResolver.java @@ -0,0 +1,194 @@ +package emu.nebula.game.gacha; + +import emu.nebula.data.GameData; +import emu.nebula.game.inventory.ItemAcquireMap; +import emu.nebula.game.inventory.ItemParamMap; +import emu.nebula.game.inventory.ItemType; +import emu.nebula.game.player.Player; +import emu.nebula.game.player.PlayerChangeInfo; +import emu.nebula.proto.Public.Transform; +import it.unimi.dsi.fastutil.ints.IntList; + +import java.util.function.IntPredicate; + +public final class GachaRewardResolver { + private static final int MAX_DISC_COUNT = 6; + private static final int ITEM_ID_TRAVEL_PERMIT = 23; + private static final int ITEM_ID_EXPERT_PERMIT = 24; + private static final int EXPERT_PERMIT_PER_DUP_CHAR = 40; + private static final int TRAVEL_PERMIT_PER_DISC = 100; + + private GachaRewardResolver() { + } + + public static boolean isResolvable(IntList cards) { + if (cards == null || cards.isEmpty()) { + return false; + } + + return isResolvable(consumer -> { + for (int id : cards) { + if (!consumer.test(id)) { + return false; + } + } + return true; + }); + } + + public static boolean isResolvable(int[] cards) { + if (cards == null || cards.length == 0) { + return false; + } + + return isResolvable(consumer -> { + for (int id : cards) { + if (!consumer.test(id)) { + return false; + } + } + return true; + }); + } + + private static boolean isResolvable(IntPredicateRunner runner) { + return runner.run(GachaRewardResolver::isResolvableItem); + } + + private static boolean isResolvableItem(int itemId) { + var itemData = GameData.getItemDataTable().get(itemId); + if (itemData == null) { + return false; + } + + if (itemData.getItemType() == ItemType.Char) { + var characterData = GameData.getCharacterDataTable().get(itemId); + return characterData != null; + } + + if (itemData.getItemType() == ItemType.Disc) { + var discData = GameData.getDiscDataTable().get(itemId); + return discData != null; + } + + return true; + } + + public static RewardPlan resolve(ItemAcquireMap acquireItems, ItemParamMap baseBonusItems) { + if (acquireItems == null) { + return null; + } + + var grantItems = new ItemParamMap(); + if (baseBonusItems != null) { + grantItems.add(baseBonusItems); + } + + var transformSrcItems = new ItemParamMap(); + var transformDstItems = new ItemParamMap(); + + int characters = 0; + + for (var entry : acquireItems.getItems().int2ObjectEntrySet()) { + int id = entry.getIntKey(); + var acquire = entry.getValue(); + + if (acquire.getType() == ItemType.Char) { + int count = acquire.getCount(); + int newCharacterCount = acquire.getBegin() == 0 ? 1 : 0; + int duplicateCount = Math.max(count - newCharacterCount, 0); + + if (duplicateCount > 0) { + var characterData = GameData.getCharacterDataTable().get(id); + if (characterData == null) { + continue; + } + + transformSrcItems.add(id, duplicateCount); + transformDstItems.add(characterData.getFragmentsId(), characterData.getTransformQty() * duplicateCount); + transformDstItems.add(ITEM_ID_EXPERT_PERMIT, EXPERT_PERMIT_PER_DUP_CHAR * duplicateCount); + } + + if (newCharacterCount > 0) { + grantItems.add(id, newCharacterCount); + } + characters += acquire.getCount(); + } else if (acquire.getType() == ItemType.Disc) { + int begin = acquire.getBegin(); + int count = acquire.getCount(); + + int newDiscCount = begin == 0 ? 1 : 0; + int duplicateCount = Math.max(count - newDiscCount, 0); + int effectiveBegin = begin + newDiscCount; + + int maxTransformCount = Math.max(MAX_DISC_COUNT - effectiveBegin, 0); + int transformCount = Math.min(duplicateCount, maxTransformCount); + int extraCount = Math.max(duplicateCount - transformCount, 0); + + if (transformCount > 0) { + var discData = GameData.getDiscDataTable().get(id); + if (discData == null) { + continue; + } + + transformSrcItems.add(id, transformCount); + transformDstItems.add(discData.getTransformItemId(), transformCount); + } + if (extraCount > 0) { + transformSrcItems.add(id, extraCount); + transformDstItems.add(ITEM_ID_TRAVEL_PERMIT, TRAVEL_PERMIT_PER_DISC * extraCount); + } + + if (newDiscCount > 0) { + grantItems.add(id, newDiscCount); + } + grantItems.add(ITEM_ID_TRAVEL_PERMIT, TRAVEL_PERMIT_PER_DISC * acquire.getCount()); + } else { + grantItems.add(id, acquire.getCount()); + } + } + + grantItems.add(transformDstItems); + return new RewardPlan( + acquireItems, + grantItems, + new TransformLog(transformSrcItems, transformDstItems), + characters + ); + } + + public static int apply(Player player, RewardPlan plan, PlayerChangeInfo change) { + if (player == null || plan == null || change == null) { + return 0; + } + + player.getInventory().addItems(plan.grantItems(), change); + change.add(plan.acquireItems().toProto()); + + var transform = Transform.newInstance(); + plan.transformLog().srcItems().toItemTemplateStream().forEach(transform::addSrc); + plan.transformLog().dstItems().toItemTemplateStream().forEach(transform::addDst); + change.add(transform); + + return plan.characterCount(); + } + + @FunctionalInterface + private interface IntPredicateRunner { + boolean run(IntPredicate predicate); + } + + public record RewardPlan( + ItemAcquireMap acquireItems, + ItemParamMap grantItems, + TransformLog transformLog, + int characterCount + ) { + } + + public record TransformLog( + ItemParamMap srcItems, + ItemParamMap dstItems + ) { + } +} diff --git a/src/main/java/emu/nebula/game/gacha/GachaRollEngine.java b/src/main/java/emu/nebula/game/gacha/GachaRollEngine.java new file mode 100644 index 0000000..92a967a --- /dev/null +++ b/src/main/java/emu/nebula/game/gacha/GachaRollEngine.java @@ -0,0 +1,167 @@ +package emu.nebula.game.gacha; + +import emu.nebula.data.resources.GachaATypeProbDef; +import emu.nebula.data.resources.GachaDef; +import emu.nebula.data.resources.GachaDef.GachaPackage; +import emu.nebula.data.resources.GachaPkgDef; +import emu.nebula.game.gacha.GachaPityState.GachaPityDraft; +import emu.nebula.util.WeightedList; +import emu.nebula.util.Utils; + +public final class GachaRollEngine { + private static final int PROBABILITY_BASE = 10000; + private static final int DEFAULT_A_TYPE_PROB = 200; + private static final int DEFAULT_B_GUARANTEE_TIMES = 10; + + private record Rates(int rollBase, int chanceA, int chanceB, int aupGuaranteeTimes, int bGuaranteeTimes) {} + + private static Rates resolveRates(GachaDef data, int missTimesA) { + int rollBase = Math.max(PROBABILITY_BASE, GachaATypeProbDef.getMaxProb()); + int aupGuaranteeTimes = 0; + int chanceA = 0; + int chanceB = 0; + int bGuaranteeTimes = 0; + + var storageData = data.getStorageData(); + if (storageData != null) { + aupGuaranteeTimes = storageData.getAUpGuaranteeTimes(); + chanceA = GachaATypeProbDef.getProb(storageData.getATypeGroup(), missTimesA, DEFAULT_A_TYPE_PROB); + chanceB = storageData.getBTypeProb(); + bGuaranteeTimes = storageData.getBGuaranteeTimes(); + + if (bGuaranteeTimes <= 0) { + bGuaranteeTimes = DEFAULT_B_GUARANTEE_TIMES; + } + + if (aupGuaranteeTimes > 0 && missTimesA >= aupGuaranteeTimes - 1) { + chanceA = rollBase; + } + } + + if (chanceA >= rollBase) { + chanceA = rollBase; + chanceB = 0; + } + + return new Rates(rollBase, chanceA, chanceB, aupGuaranteeTimes, bGuaranteeTimes); + } + + private static GachaPackage choosePackage(GachaDef data, int random, int chanceA, int chanceB, boolean forceAUp) { + if (forceAUp) { + if (data.getATypeUpPkg() > 0) { + return new GachaPackage(GachaDef.GachaPackageType.A_UP, data.getATypeUpPkg()); + } + return safeNext(data.getPackageA()); + } + + if (random <= chanceA) { + return safeNext(data.getPackageA()); + } + if (random <= chanceB) { + return safeNext(data.getPackageB()); + } + + return safeNext(data.getPackageC()); + } + + private static GachaPackage safeNext(WeightedList list) { + if (list == null || list.size() == 0) { + return null; + } + return list.next(); + } + + private static RollOutcome roll(GachaDef data, int missTimesA, int missTimesUpA, int missTimesB, boolean bGuaranteeDebt) { + Rates rates = resolveRates(data, missTimesA); + + boolean forceAUp = rates.aupGuaranteeTimes > 0 && missTimesUpA >= rates.aupGuaranteeTimes - 1; + + int chanceA = rates.chanceA; + int chanceB = rates.chanceB; + boolean bGuaranteeTriggered = false; + + if (rates.bGuaranteeTimes > 0 && missTimesB >= rates.bGuaranteeTimes - 1) { + chanceB = rates.rollBase; + bGuaranteeTriggered = true; + } + + int newMissTimesA = missTimesA + 1; + int newMissTimesB = missTimesB + 1; + int newMissTimesUpA = missTimesUpA; + + int random = Utils.randomRange(1, rates.rollBase); + GachaPackage gachaPackage = choosePackage(data, random, chanceA, chanceB, forceAUp); + + boolean newBGuaranteeDebt = bGuaranteeDebt; + + if (forceAUp || random <= chanceA) { + newMissTimesA = 0; + if (bGuaranteeTriggered) { + newBGuaranteeDebt = true; + } + } else if (random <= chanceB) { + newMissTimesB = 0; + newBGuaranteeDebt = false; + } + + if (newBGuaranteeDebt && gachaPackage != null && gachaPackage.getType() == GachaDef.GachaPackageType.C) { + var compensated = safeNext(data.getPackageB()); + if (compensated != null) { + gachaPackage = compensated; + newMissTimesB = 0; + newBGuaranteeDebt = false; + } + } + + if (gachaPackage != null && gachaPackage.getType() == GachaDef.GachaPackageType.A_UP) { + newMissTimesUpA = 0; + } else { + newMissTimesUpA++; + } + + return new RollOutcome(gachaPackage, newMissTimesA, newMissTimesUpA, newMissTimesB, newBGuaranteeDebt); + } + + public static PullOutcome pull(GachaDef data, GachaPityDraft pityDraft) { + if (data == null || pityDraft == null) { + return null; + } + + var outcome = roll(data, pityDraft.missTimesA(), pityDraft.missTimesUpA(), + pityDraft.missTimesB(), pityDraft.bGuaranteeDebt()); + + var gachaPackage = outcome.gachaPackage(); + if (gachaPackage == null) { + return null; + } + + var pkg = GachaPkgDef.getPackageById(gachaPackage.getId()); + if (pkg == null) { + return null; + } + + return new PullOutcome( + pkg.next(), + new GachaPityDraft( + outcome.missTimesA(), + outcome.missTimesUpA(), + outcome.missTimesB(), + outcome.bGuaranteeDebt() + ) + ); + } + + public record RollOutcome( + GachaPackage gachaPackage, + int missTimesA, + int missTimesUpA, + int missTimesB, + boolean bGuaranteeDebt + ) {} + + public record PullOutcome( + int itemId, + GachaPityDraft pityDraft + ) { + } +} diff --git a/src/main/java/emu/nebula/game/gacha/NewbieGachaModule.java b/src/main/java/emu/nebula/game/gacha/NewbieGachaModule.java new file mode 100644 index 0000000..a53c871 --- /dev/null +++ b/src/main/java/emu/nebula/game/gacha/NewbieGachaModule.java @@ -0,0 +1,212 @@ +package emu.nebula.game.gacha; + +import emu.nebula.data.GameData; +import emu.nebula.data.resources.GachaNewbieDef; +import emu.nebula.game.inventory.ItemAcquireMap; +import emu.nebula.game.player.Player; +import emu.nebula.game.player.PlayerChangeInfo; +import emu.nebula.proto.GachaNewbieInfoOuterClass.GachaNewbieInfo; +import emu.nebula.proto.GachaNewbieInfoOuterClass.UI32s; +import it.unimi.dsi.fastutil.ints.IntArrayList; + +import java.util.ArrayList; +import java.util.List; + +public final class NewbieGachaModule { + + private record NewbieRequest(GachaManager manager, GachaNewbieDef newbieDef) { + } + + public List listInfos(Player player) { + var newbieDefs = GameData.getGachaNewbieDataTable().values(); + var infos = new ArrayList(newbieDefs.size()); + var manager = player.getGachaManager(); + + synchronized (manager) { + for (var data : newbieDefs) { + var state = manager.getOrCreateNewbieState(data); + boolean received = state.isReceived(); + int usedSpinCount = Math.max(0, data.getSpinCount() - state.getRemainingSpinCount()); + + var info = GachaNewbieInfo.newInstance() + .setId(data.getId()) + .setTimes(usedSpinCount) + .setReceive(received); + + if (!received) { + var pendingResult = state.getPendingResult(); + if (pendingResult != null) { + info.getMutableTemp().addAllValues(pendingResult); + } + + for (var cards : state.getSavedResults()) { + if (cards != null && cards.length > 0) { + info.addCards(UI32s.newInstance().addAllValues(cards)); + } + } + } + + infos.add(info); + } + } + + return infos; + } + + public int[] spin(Player player, int newbieId) { + var request = resolveRequest(player, newbieId); + if (request == null) { + return null; + } + + int newbieStateId = request.newbieDef().getId(); + var bannerDef = GameData.getGachaDataTable().get(newbieStateId); + if (bannerDef == null) { + return null; + } + + var manager = request.manager(); + synchronized (manager) { + var state = loadStateForSpin(manager, request.newbieDef()); + if (state == null) { + return null; + } + + int[] cards = NewbieRollStrategy.rollTenPull(bannerDef, NewbieRollStrategy.defaultProfile()); + if (!GachaRewardResolver.isResolvable(cards)) { + return null; + } + + if (!state.applySpinResult(cards)) { + return null; + } + + manager.saveNewbieState(state); + return cards; + } + } + + public boolean save(Player player, int newbieId, Integer index) { + int resolvedIndex = index == null ? -1 : index; + var request = resolveRequest(player, newbieId); + if (request == null) { + return false; + } + + var manager = request.manager(); + synchronized (manager) { + var state = loadStateForSave(manager, request.newbieDef()); + if (state == null) { + return false; + } + + if (!state.savePendingResult(resolvedIndex)) { + return false; + } + + manager.saveNewbieState(state); + return true; + } + } + + public PlayerChangeInfo obtain(Player player, int newbieId, int index) { + if (index < 0) { + return null; + } + + var request = resolveRequest(player, newbieId); + if (request == null) { + return null; + } + + int newbieStateId = request.newbieDef().getId(); + var obtainLock = request.manager().lockNewbieObtain(newbieStateId); + if (obtainLock == null) { + return null; + } + + try (var ignored = obtainLock) { + NewbieGachaState state; + int[] cards; + synchronized (request.manager()) { + state = loadStateForObtain(request.manager(), request.newbieDef(), index); + if (state == null) { + return null; + } + + cards = state.copySavedResult(index); + if (!GachaRewardResolver.isResolvable(cards)) { + return null; + } + } + + var change = new PlayerChangeInfo(); + var acquireItems = new ItemAcquireMap(player, new IntArrayList(cards)); + var rewardPlan = GachaRewardResolver.resolve(acquireItems, null); + if (rewardPlan == null) { + return null; + } + + synchronized (request.manager()) { + if (!state.markReceived(index)) { + return null; + } + + request.manager().saveNewbieState(state); + } + + GachaRewardResolver.apply(player, rewardPlan, change); + return change; + } + } + + private NewbieRequest resolveRequest(Player player, int newbieId) { + var newbieDef = GameData.getGachaNewbieDataTable().get(newbieId); + if (newbieDef == null) { + return null; + } + + return new NewbieRequest(player.getGachaManager(), newbieDef); + } + + private NewbieGachaState loadStateForSpin(GachaManager manager, GachaNewbieDef newbieDef) { + int newbieStateId = newbieDef.getId(); + if (manager.isNewbieObtainLocked(newbieStateId)) { + return null; + } + var state = manager.getOrCreateNewbieState(newbieDef); + if (!state.canSpin(false)) { + return null; + } + + return state; + } + + private NewbieGachaState loadStateForSave(GachaManager manager, + GachaNewbieDef newbieDef) { + int newbieStateId = newbieDef.getId(); + if (manager.isNewbieObtainLocked(newbieStateId)) { + return null; + } + var state = manager.getOrCreateNewbieState(newbieDef); + if (!state.canSavePendingResult(false)) { + return null; + } + + return state; + } + + private NewbieGachaState loadStateForObtain(GachaManager manager, GachaNewbieDef newbieDef, int index) { + int newbieStateId = newbieDef.getId(); + var state = manager.findNewbieState(newbieStateId); + if (state == null) { + return null; + } + if (!state.canObtain(index)) { + return null; + } + + return state; + } + +} diff --git a/src/main/java/emu/nebula/game/gacha/NewbieGachaState.java b/src/main/java/emu/nebula/game/gacha/NewbieGachaState.java new file mode 100644 index 0000000..cd0d99b --- /dev/null +++ b/src/main/java/emu/nebula/game/gacha/NewbieGachaState.java @@ -0,0 +1,101 @@ +package emu.nebula.game.gacha; + +import java.util.*; +import dev.morphia.annotations.Entity; +import lombok.Getter; + +@Getter +@Entity(useDiscriminator = false) +public class NewbieGachaState { + private int id; + private int remainingSpinCount; + private int saveCount = 1; + private int selectedResult = -1; + private boolean received; + private int[] pendingResult; + private final List savedResults = new ArrayList<>(); + + @Deprecated + public NewbieGachaState() { + } + + public NewbieGachaState(int id, int spinCount, int saveCount) { + this.id = id; + this.remainingSpinCount = Math.max(0, spinCount); + this.saveCount = Math.max(1, saveCount); + } + + public boolean hasPendingResult() { + return pendingResult != null && pendingResult.length > 0; + } + + // Checks if the player can perform a spin. + public boolean canSpin(boolean obtainLocked) { + return !received && !obtainLocked && remainingSpinCount > 0; + } + + // Checks if the current pending result can be moved to saved results + public boolean canSavePendingResult(boolean obtainLocked) { + return !received && !obtainLocked && hasPendingResult(); + } + + // Updates the maximum allowed saved results. + public boolean applyConfig(int saveCount) { + int oldSaveCount = this.saveCount; + this.saveCount = Math.max(1, saveCount); + return this.saveCount != oldSaveCount; + } + + // Applies a new spin result to the pending slot and consumes a spin attempt + public boolean applySpinResult(int[] cards) { + if (!canSpin(false) || cards == null || cards.length == 0) { + return false; + } + + this.pendingResult = cards; + this.remainingSpinCount--; + return true; + } + + // Saves the pending result into the saved results list at the specified index or adds it + public boolean savePendingResult(int index) { + if (!hasPendingResult() || received) return false; + + if (index >= 0 && index < savedResults.size()) { + // Replace existing slot + savedResults.set(index, pendingResult); + } else if (savedResults.size() < saveCount) { + // Add new slot if capacity allows + savedResults.add(pendingResult); + } else { + return false; + } + + this.pendingResult = null; + return true; + } + + // Returns a clone of the saved result at the given index + public int[] copySavedResult(int index) { + return (index >= 0 && index < savedResults.size()) ? savedResults.get(index).clone() : null; + } + + // Check for claiming a specific result + public boolean canObtain(int index) { + return !received && index >= 0 && index < savedResults.size(); + } + + // Marks a specific saved result as claimed and closes the gacha session + public boolean markReceived(int index) { + if (received || index < 0 || index >= savedResults.size()) { + return false; + } + + this.selectedResult = index; + this.received = true; + this.remainingSpinCount = 0; + this.pendingResult = null; + return true; + } + +} diff --git a/src/main/java/emu/nebula/game/gacha/NewbieRollStrategy.java b/src/main/java/emu/nebula/game/gacha/NewbieRollStrategy.java new file mode 100644 index 0000000..b574ad9 --- /dev/null +++ b/src/main/java/emu/nebula/game/gacha/NewbieRollStrategy.java @@ -0,0 +1,121 @@ +package emu.nebula.game.gacha; + +import java.util.Collections; +import java.util.concurrent.ThreadLocalRandom; + +import emu.nebula.data.resources.GachaDef; +import emu.nebula.data.resources.GachaPkgDef; +import emu.nebula.util.WeightedList; +import it.unimi.dsi.fastutil.ints.IntArrayList; + +public final class NewbieRollStrategy { + private static final int TEN_PULL_COUNT = 10; + private static final int MAX_FIVE_COUNT = 1; + private static final int MIN_FOUR_COUNT = 1; + private static final int MAX_FOUR_COUNT_WITH_FIVE = 2; + private static final int MAX_FOUR_COUNT_WITHOUT_FIVE = 3; + private static final double DEFAULT_HAS_FIVE_RATE = 0.75; + // Chance to upgrade 4-star count from 1 to multi-4 (2 or 3). + private static final double DEFAULT_MULTI_FOUR_WHEN_FIVE_RATE = 10d / 15d; + // In multi-4 results, chance to output 3 four-stars instead of 2. + private static final double DEFAULT_THREE_FOUR_WHEN_MULTI_RATE = 0.35; + + /** + * Newbie 10-pull shape profile: + * - hasFiveRate: chance that this 10-pull contains exactly one 5-star + * - multiFourWhenFiveRate: chance to upgrade 4-star count from 1 to multi-4 + * - threeFourWhenMultiRate: in no-5-star multi-4 results, chance of 3 four-stars (else 2) + */ + public record Profile(double hasFiveRate, double multiFourWhenFiveRate, double threeFourWhenMultiRate) {} + + private static final Profile DEFAULT_PROFILE = new Profile( + DEFAULT_HAS_FIVE_RATE, + DEFAULT_MULTI_FOUR_WHEN_FIVE_RATE, + DEFAULT_THREE_FOUR_WHEN_MULTI_RATE + ); + + public static Profile defaultProfile() { + return DEFAULT_PROFILE; + } + + private record PullCounts(int fiveCount, int fourCount, int threeCount) {} + + private static PullCounts resolvePullCounts(ThreadLocalRandom random, Profile profile) { + // Rule 1: a newbie 10-pull contains at most one 5-star + boolean hasFiveStar = random.nextDouble() < profile.hasFiveRate(); + int fiveCount = hasFiveStar ? MAX_FIVE_COUNT : 0; + + int fourCount = MIN_FOUR_COUNT; + if (hasFiveStar) { + // With a 5-star present, 4-star count is constrained to 1~2 + fourCount = random.nextDouble() < profile.multiFourWhenFiveRate() ? MAX_FOUR_COUNT_WITH_FIVE : MIN_FOUR_COUNT; + } else { + // Without a 5-star, still guarantee at least one 4-star and allow up to 3 + if (random.nextDouble() < profile.multiFourWhenFiveRate()) { + boolean rollThreeFours = random.nextDouble() < profile.threeFourWhenMultiRate(); + fourCount = rollThreeFours ? MAX_FOUR_COUNT_WITHOUT_FIVE : 2; + } + } + + int threeCount = TEN_PULL_COUNT - fiveCount - fourCount; + return new PullCounts(fiveCount, fourCount, threeCount); + } + + private static int rollFromPackage(GachaDef.GachaPackage gachaPackage) { + if (gachaPackage == null) { + return 0; + } + + var pkg = GachaPkgDef.getPackageById(gachaPackage.getId()); + if (pkg == null || pkg.size() == 0) { + return 0; + } + + return pkg.next(); + } + + private static boolean appendCardsFromTier(IntArrayList cards, WeightedList tier, int count) { + for (int i = 0; i < count; i++) { + int cardId = rollFromPackage(tier.next()); + if (cardId <= 0) { + return false; + } + + cards.add(cardId); + } + + return true; + } + + public static int[] rollTenPull(GachaDef bannerData, Profile profile) { + if (bannerData == null) { + return null; + } + + var packageA = bannerData.getPackageA(); + var packageB = bannerData.getPackageB(); + var packageC = bannerData.getPackageC(); + if (packageA == null || packageA.size() == 0 || packageB == null || packageB.size() == 0 || packageC == null || packageC.size() == 0) { + return null; + } + + var random = ThreadLocalRandom.current(); + + // Step 1: decide rarity counts for this newbie 10-pull + PullCounts counts = resolvePullCounts(random, profile); + + // Step 2: draw concrete item ids from A/B/C packages + var cards = new IntArrayList(TEN_PULL_COUNT); + if (!appendCardsFromTier(cards, packageA, counts.fiveCount()) + || !appendCardsFromTier(cards, packageB, counts.fourCount()) + || !appendCardsFromTier(cards, packageC, counts.threeCount()) + || cards.size() != TEN_PULL_COUNT) { + return null; + } + + // Step 3: shuffle cards order + Collections.shuffle(cards, random); + + return cards.toIntArray(); + } +} diff --git a/src/main/java/emu/nebula/game/player/PlayerErrorCode.java b/src/main/java/emu/nebula/game/player/PlayerErrorCode.java index 21ad15d..325a4f8 100644 --- a/src/main/java/emu/nebula/game/player/PlayerErrorCode.java +++ b/src/main/java/emu/nebula/game/player/PlayerErrorCode.java @@ -177,10 +177,10 @@ public enum PlayerErrorCode { ErrCondNotMet(119906), ErrInsufficientWorldClass(119907), ErrNoRewardsToReceive(119908), - ErrAlreayExists(119909), + ErrAlreadyExists(119909), ErrExchangeNotSupported(119910), ErrRequestTooFrequent(119911), - ErrAlreaydReceive(119912), + ErrAlreadyReceive(119912), ErrLimit(119913), ErrDataUpdated(119914); diff --git a/src/main/java/emu/nebula/server/handlers/HandlerGachaGuaranteeRewardReceiveReq.java b/src/main/java/emu/nebula/server/handlers/HandlerGachaGuaranteeRewardReceiveReq.java index 214fe1e..ea5870b 100644 --- a/src/main/java/emu/nebula/server/handlers/HandlerGachaGuaranteeRewardReceiveReq.java +++ b/src/main/java/emu/nebula/server/handlers/HandlerGachaGuaranteeRewardReceiveReq.java @@ -1,5 +1,6 @@ package emu.nebula.server.handlers; +import emu.nebula.Nebula; import emu.nebula.net.NetHandler; import emu.nebula.net.NetMsgId; import emu.nebula.proto.Public.UI32; @@ -14,8 +15,8 @@ public class HandlerGachaGuaranteeRewardReceiveReq extends NetHandler { // Parse req var req = UI32.parseFrom(message); - // Recieve guaranteed reward - var change = session.getPlayer().getGachaManager().recvGuarantee(req.getValue()); + // Receive guaranteed reward + var change = Nebula.getGameContext().getGachaModule().recvGuarantee(session.getPlayer(), req.getValue()); if (change == null) { return session.encodeMsg(NetMsgId.gacha_guarantee_reward_receive_failed_ack); diff --git a/src/main/java/emu/nebula/server/handlers/HandlerGachaHistoriesReq.java b/src/main/java/emu/nebula/server/handlers/HandlerGachaHistoriesReq.java index 5e280ee..ccdec5e 100644 --- a/src/main/java/emu/nebula/server/handlers/HandlerGachaHistoriesReq.java +++ b/src/main/java/emu/nebula/server/handlers/HandlerGachaHistoriesReq.java @@ -1,32 +1,48 @@ package emu.nebula.server.handlers; +import emu.nebula.Nebula; +import emu.nebula.data.GameData; +import emu.nebula.net.GameSession; +import emu.nebula.net.HandlerId; import emu.nebula.net.NetHandler; import emu.nebula.net.NetMsgId; import emu.nebula.proto.GachaHistoriesOuterClass.GachaHistories; import emu.nebula.proto.Public.UI32; -import emu.nebula.net.HandlerId; -import emu.nebula.net.GameSession; @HandlerId(NetMsgId.gacha_histories_req) public class HandlerGachaHistoriesReq extends NetHandler { + private static final int MAX_HISTORY_SIZE = 2000; + // Return histories from the recent 6 months only + private static final long HISTORY_RETENTION_SECONDS = 60L * 60L * 24L * 30L * 6L; @Override public byte[] handle(GameSession session, byte[] message) throws Exception { // Parse request var req = UI32.parseFrom(message); - - // Get history log - var list = session.getPlayer().getGachaManager().getHistories().get(req.getValue()); - - // Build response - var rsp = GachaHistories.newInstance(); - - if (list != null) { - for (var log : list) { - rsp.addList(log.toProto()); - } + int requestedStorageId = req.getValue(); + if (requestedStorageId <= 0 || GameData.getGachaStorageDataTable().get(requestedStorageId) == null) { + return session.encodeMsg(NetMsgId.gacha_histories_failed_ack); } - + + long retentionStartTime = Nebula.getCurrentServerTime() - HISTORY_RETENTION_SECONDS; + + var rsp = GachaHistories.newInstance(); + var logs = Nebula.getGameDatabase().getSortedObjects( + emu.nebula.game.gacha.GachaHistoryLog.class, + "playerUid", + session.getPlayer().getUid(), + "type", + requestedStorageId, + "time", + retentionStartTime, + "time", + MAX_HISTORY_SIZE + ); + java.util.Collections.reverse(logs); + for (var log : logs) { + rsp.addList(log.toProto()); + } + // Encode and send return session.encodeMsg(NetMsgId.gacha_histories_succeed_ack, rsp); } diff --git a/src/main/java/emu/nebula/server/handlers/HandlerGachaInformationReq.java b/src/main/java/emu/nebula/server/handlers/HandlerGachaInformationReq.java index 208e582..a37589a 100644 --- a/src/main/java/emu/nebula/server/handlers/HandlerGachaInformationReq.java +++ b/src/main/java/emu/nebula/server/handlers/HandlerGachaInformationReq.java @@ -1,10 +1,13 @@ package emu.nebula.server.handlers; +import emu.nebula.Nebula; +import emu.nebula.data.GameData; +import emu.nebula.game.gacha.GachaBannerInfo; +import emu.nebula.net.GameSession; +import emu.nebula.net.HandlerId; import emu.nebula.net.NetHandler; import emu.nebula.net.NetMsgId; import emu.nebula.proto.GachaInformation.GachaInformationResp; -import emu.nebula.net.HandlerId; -import emu.nebula.net.GameSession; @HandlerId(NetMsgId.gacha_information_req) public class HandlerGachaInformationReq extends NetHandler { @@ -13,9 +16,21 @@ public class HandlerGachaInformationReq extends NetHandler { public byte[] handle(GameSession session, byte[] message) throws Exception { // Build response var rsp = GachaInformationResp.newInstance(); - - for (var bannerInfo : session.getPlayer().getGachaManager().getBannerInfos()) { - rsp.addInformation(bannerInfo.toProto()); + var manager = session.getPlayer().getGachaManager(); + synchronized (manager) { + for (var data : GameData.getGachaDataTable().values()) { + if (!data.isActiveAt(Nebula.getCurrentServerTime())) { + continue; + } + + var bannerInfo = manager.findBannerInfo(data.getId()); + if (bannerInfo == null) { + bannerInfo = new GachaBannerInfo(data); + } + + var pityState = manager.findPityState(data.getStorageId()); + rsp.addInformation(bannerInfo.toProto(pityState)); + } } // Encode and send diff --git a/src/main/java/emu/nebula/server/handlers/HandlerGachaNewbieInfoReq.java b/src/main/java/emu/nebula/server/handlers/HandlerGachaNewbieInfoReq.java index 2171ea3..15dafdf 100644 --- a/src/main/java/emu/nebula/server/handlers/HandlerGachaNewbieInfoReq.java +++ b/src/main/java/emu/nebula/server/handlers/HandlerGachaNewbieInfoReq.java @@ -1,11 +1,11 @@ package emu.nebula.server.handlers; +import emu.nebula.Nebula; +import emu.nebula.net.GameSession; +import emu.nebula.net.HandlerId; import emu.nebula.net.NetHandler; import emu.nebula.net.NetMsgId; -import emu.nebula.proto.GachaNewbieInfoOuterClass.GachaNewbieInfo; import emu.nebula.proto.GachaNewbieInfoOuterClass.GachaNewbieInfoResp; -import emu.nebula.net.HandlerId; -import emu.nebula.net.GameSession; @HandlerId(NetMsgId.gacha_newbie_info_req) public class HandlerGachaNewbieInfoReq extends NetHandler { @@ -13,13 +13,9 @@ public class HandlerGachaNewbieInfoReq extends NetHandler { @Override public byte[] handle(GameSession session, byte[] message) throws Exception { var rsp = GachaNewbieInfoResp.newInstance(); - - var info = GachaNewbieInfo.newInstance() - .setId(5) - .setReceive(true); - - rsp.addList(info); - + for (var info : Nebula.getGameContext().getGachaModule().listNewbieInfos(session.getPlayer())) { + rsp.addList(info); + } return session.encodeMsg(NetMsgId.gacha_newbie_info_succeed_ack, rsp); } diff --git a/src/main/java/emu/nebula/server/handlers/HandlerGachaNewbieObtainReq.java b/src/main/java/emu/nebula/server/handlers/HandlerGachaNewbieObtainReq.java index 30097b1..526e66e 100644 --- a/src/main/java/emu/nebula/server/handlers/HandlerGachaNewbieObtainReq.java +++ b/src/main/java/emu/nebula/server/handlers/HandlerGachaNewbieObtainReq.java @@ -1,21 +1,28 @@ package emu.nebula.server.handlers; +import emu.nebula.Nebula; +import emu.nebula.net.GameSession; +import emu.nebula.net.HandlerId; import emu.nebula.net.NetHandler; import emu.nebula.net.NetMsgId; import emu.nebula.proto.GachaNewbieObtain.GachaNewbieObtainReq; -import emu.nebula.net.HandlerId; -import emu.nebula.net.GameSession; @HandlerId(NetMsgId.gacha_newbie_obtain_req) public class HandlerGachaNewbieObtainReq extends NetHandler { @Override public byte[] handle(GameSession session, byte[] message) throws Exception { - @SuppressWarnings("unused") var req = GachaNewbieObtainReq.parseFrom(message); - - // TODO - return session.encodeMsg(NetMsgId.gacha_newbie_obtain_failed_ack); + var change = Nebula.getGameContext().getGachaModule().obtainNewbie(session.getPlayer(), req.getId(), req.getIdx()); + if (change == null) { + return session.encodeMsg(NetMsgId.gacha_newbie_obtain_failed_ack); + } + + if (!change.isEmpty()) { + session.getPlayer().addNextPackage(NetMsgId.items_change_notify, change.toProto()); + } + + return session.encodeMsg(NetMsgId.gacha_newbie_obtain_succeed_ack, change.toProto()); } } diff --git a/src/main/java/emu/nebula/server/handlers/HandlerGachaNewbieSaveReq.java b/src/main/java/emu/nebula/server/handlers/HandlerGachaNewbieSaveReq.java new file mode 100644 index 0000000..aa8f55b --- /dev/null +++ b/src/main/java/emu/nebula/server/handlers/HandlerGachaNewbieSaveReq.java @@ -0,0 +1,25 @@ +package emu.nebula.server.handlers; + +import emu.nebula.Nebula; +import emu.nebula.net.GameSession; +import emu.nebula.net.HandlerId; +import emu.nebula.net.NetHandler; +import emu.nebula.net.NetMsgId; +import emu.nebula.proto.GachaNewbieSave.GachaNewbieSaveReq; + +@HandlerId(NetMsgId.gacha_newbie_save_req) +public class HandlerGachaNewbieSaveReq extends NetHandler { + + @Override + public byte[] handle(GameSession session, byte[] message) throws Exception { + var req = GachaNewbieSaveReq.parseFrom(message); + Integer index = req.hasIdx() ? req.getIdx() : null; + boolean succeeded = Nebula.getGameContext().getGachaModule().saveNewbie(session.getPlayer(), req.getId(), index); + if (!succeeded) { + return session.encodeMsg(NetMsgId.gacha_newbie_save_failed_ack); + } + + return session.encodeMsg(NetMsgId.gacha_newbie_save_succeed_ack); + } + +} diff --git a/src/main/java/emu/nebula/server/handlers/HandlerGachaNewbieSpinReq.java b/src/main/java/emu/nebula/server/handlers/HandlerGachaNewbieSpinReq.java index 9e91224..8d00370 100644 --- a/src/main/java/emu/nebula/server/handlers/HandlerGachaNewbieSpinReq.java +++ b/src/main/java/emu/nebula/server/handlers/HandlerGachaNewbieSpinReq.java @@ -1,16 +1,33 @@ package emu.nebula.server.handlers; +import emu.nebula.Nebula; +import emu.nebula.game.gacha.GachaMode; +import emu.nebula.net.GameSession; +import emu.nebula.net.HandlerId; import emu.nebula.net.NetHandler; import emu.nebula.net.NetMsgId; -import emu.nebula.net.HandlerId; -import emu.nebula.net.GameSession; +import emu.nebula.proto.GachaNewbieSpin.GachaNewbieSpinResp; +import emu.nebula.proto.GachaSpin.GachaSpinReq; @HandlerId(NetMsgId.gacha_newbie_spin_req) public class HandlerGachaNewbieSpinReq extends NetHandler { - @Override public byte[] handle(GameSession session, byte[] message) throws Exception { - return session.encodeMsg(NetMsgId.gacha_newbie_spin_failed_ack); + var req = GachaSpinReq.parseFrom(message); + Integer amount = GachaMode.getAmountByMode(req.getMode()); + if (amount == null) { + return session.encodeMsg(NetMsgId.gacha_newbie_spin_failed_ack); + } + + int[] cards = Nebula.getGameContext().getGachaModule().spinNewbie(session.getPlayer(), req.getId()); + if (cards == null) { + return session.encodeMsg(NetMsgId.gacha_newbie_spin_failed_ack); + } + + var rsp = GachaNewbieSpinResp.newInstance(); + rsp.addAllCards(cards); + + return session.encodeMsg(NetMsgId.gacha_newbie_spin_succeed_ack, rsp); } } diff --git a/src/main/java/emu/nebula/server/handlers/HandlerGachaSpinReq.java b/src/main/java/emu/nebula/server/handlers/HandlerGachaSpinReq.java index 8aa9704..967c29f 100644 --- a/src/main/java/emu/nebula/server/handlers/HandlerGachaSpinReq.java +++ b/src/main/java/emu/nebula/server/handlers/HandlerGachaSpinReq.java @@ -1,14 +1,13 @@ package emu.nebula.server.handlers; +import emu.nebula.Nebula; +import emu.nebula.data.GameData; +import emu.nebula.game.gacha.GachaMode; +import emu.nebula.net.GameSession; +import emu.nebula.net.HandlerId; import emu.nebula.net.NetHandler; import emu.nebula.net.NetMsgId; -import emu.nebula.net.HandlerId; -import emu.nebula.Nebula; -import emu.nebula.net.GameSession; -import emu.nebula.proto.GachaSpin.GachaCard; import emu.nebula.proto.GachaSpin.GachaSpinReq; -import emu.nebula.proto.GachaSpin.GachaSpinResp; -import emu.nebula.proto.Public.ItemTpl; @HandlerId(NetMsgId.gacha_spin_req) public class HandlerGachaSpinReq extends NetHandler { @@ -17,34 +16,24 @@ public class HandlerGachaSpinReq extends NetHandler { public byte[] handle(GameSession session, byte[] message) throws Exception { // Parse request var req = GachaSpinReq.parseFrom(message); + Integer amount = GachaMode.getAmountByMode(req.getMode()); + if (amount == null) { + return session.encodeMsg(NetMsgId.gacha_spin_failed_ack); + } // Do gacha var result = Nebula.getGameContext().getGachaModule().spin( session.getPlayer(), req.getId(), - req.getMode() + amount ); if (result == null) { return session.encodeMsg(NetMsgId.gacha_spin_failed_ack); } - - // Build response - var rsp = GachaSpinResp.newInstance() - .setTime(Nebula.getCurrentServerTime()) - .setAMissTimes(result.getInfo().getMissTimesA()) - .setAupMissTimes(result.getInfo().getMissTimesA()) - .setTotalTimes(result.getInfo().getTotal()) - .setGachaTotalTimes(result.getInfo().getTotal()) - .setAupGuaranteeTimes(result.getInfo().isUsedGuarantee() ? 0 : 1) - .setChange(result.getChange().toProto()); - - for (int id : result.getCards()) { - var card = GachaCard.newInstance() - .setCard(ItemTpl.newInstance().setTid(id).setQty(1)); - - rsp.addCards(card); - } + + var gachaData = GameData.getGachaDataTable().get(req.getId()); + var rsp = result.toSpinResp(gachaData); // Encode and send response return session.encodeMsg(NetMsgId.gacha_spin_succeed_ack, rsp);