Reimplement gacha (#32)

* Fix some grammar warning

* Reimplement gacha

* Update GachaResult#toSpinResp declaration

---------

Co-authored-by: Yostarcc <lubenwei7758258@gmail.com>
This commit is contained in:
Yostarcc
2026-05-05 21:49:21 -07:00
committed by GitHub
co-authored by Yostarcc
parent c285c878f1
commit 1316654c15
32 changed files with 1992 additions and 450 deletions
+21 -15
View File
@@ -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();
+5 -14
View File
@@ -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<DictionaryEntryDef> DictionaryEntryDataTable = new DataTable<>();
// ===== Gacha =====
@Getter private static DataTable<GachaATypeProbDef> GachaATypeProbDataTable = new DataTable<>();
@Getter private static DataTable<GachaDef> GachaDataTable = new DataTable<>();
@Getter private static DataTable<GachaNewbieDef> GachaNewbieDataTable = new DataTable<>();
@Getter private static DataTable<GachaStorageDef> GachaStorageDataTable = new DataTable<>();
@Getter private static DataTable<GachaTypeDef> GachaTypeDataTable = new DataTable<>();
// ===== Story =====
@Getter private static DataTable<StoryDef> StoryDataTable = new DataTable<>();
@@ -165,7 +156,7 @@ public class GameData {
// Activity: Trials
@Getter private static DataTable<TrialControlDef> TrialControlDataTable = new DataTable<>();
@Getter private static DataTable<TrialGroupDef> TrialGroupDataTable = new DataTable<>();
// Activity: Joint Drill
@Getter private static DataTable<JointDrill2LevelDef> JointDrill2LevelDataTable = new DataTable<>();
@@ -175,7 +166,7 @@ public class GameData {
// Activity: Task
@Getter private static DataTable<ActivityTaskDef> ActivityTaskDataTable = new DataTable<>();
@Getter private static DataTable<ActivityTaskGroupDef> ActivityTaskGroupDataTable = new DataTable<>();
// Activity: Shop
@Getter private static DataTable<ActivityShopDef> ActivityShopDataTable = new DataTable<>();
@Getter private static DataTable<ActivityShopControlDef> ActivityShopControlDataTable = new DataTable<>();
@@ -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<T>) field.get(null);
} catch (Exception e) {
// ignore exception
} finally {
field.setAccessible(false);
}
@@ -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<Int2IntMap> 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;
}
}
@@ -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<GachaPackage> packageA;
private transient WeightedList<GachaPackage> packageB;
private transient WeightedList<GachaPackage> 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<GachaPackage>();
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<GachaPackage>();
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<GachaPackage>();
if (this.CTypePkg > 0) {
packageC.add(
10000,
new GachaPackage(GachaPackageType.C, this.CTypePkg)
);
}
if (!isSpinConfigValid()) {
markInvalid("spin package/probability composition is invalid");
}
}
@Getter
@@ -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;
}
}
@@ -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);
}
}
}
@@ -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);
}
}
@@ -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 <T> List<T> getSortedObjects(
Class<T> 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 <T> 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<String, Object> fields) {
if (fields == null || fields.isEmpty()) {
return;
}
var operators = new ArrayList<UpdateOperator>();
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
@@ -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
) {
}
}
@@ -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<Integer, LegacyBannerState> banners;
Map<Integer, List<LegacyHistoryRow>> 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<Integer> ids = new ArrayList<>();
}
private static Document derivePityStatesFromBanners(Map<Integer, LegacyBannerState> 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<org.bson.conversions.Bson>();
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<UpdateOneModel<Document>>();
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
);
}
}
@@ -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())
@@ -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<Integer, GachaBannerInfo> banners;
private Map<Integer, List<GachaHistoryLog>> histories;
private Map<Integer, GachaBannerInfo> banners = new HashMap<>();
private Map<Integer, GachaPityState> pityStates = new HashMap<>();
private Map<Integer, NewbieGachaState> newbieStates = new HashMap<>();
private transient Set<Integer> 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<GachaBannerInfo> 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<String, Object>();
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();
}
}
@@ -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<GachaMode> 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;
}
}
@@ -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<GachaNewbieInfo> 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
) {
}
}
@@ -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
) {
}
}
@@ -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;
}
}
@@ -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
) {
}
}
@@ -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<GachaPackage> 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
) {
}
}
@@ -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<GachaNewbieInfo> listInfos(Player player) {
var newbieDefs = GameData.getGachaNewbieDataTable().values();
var infos = new ArrayList<GachaNewbieInfo>(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;
}
}
@@ -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<int[]> 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;
}
}
@@ -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<GachaDef.GachaPackage> 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();
}
}
@@ -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);
@@ -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);
@@ -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);
}
@@ -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
@@ -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);
}
@@ -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());
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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);