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
+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);
}
}