mirror of
https://github.com/Melledy/Nebula.git
synced 2026-09-19 17:19:58 +02:00
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:
+1
-1
@@ -76,7 +76,7 @@ tmp/
|
|||||||
|
|
||||||
# Extra
|
# Extra
|
||||||
Nebula Handbook.txt
|
Nebula Handbook.txt
|
||||||
config.json
|
data/*
|
||||||
patchlist.json
|
patchlist.json
|
||||||
*.mv
|
*.mv
|
||||||
*.exe
|
*.exe
|
||||||
|
|||||||
@@ -1,20 +1,12 @@
|
|||||||
package emu.nebula;
|
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.Gson;
|
||||||
import com.google.gson.GsonBuilder;
|
import com.google.gson.GsonBuilder;
|
||||||
|
|
||||||
import emu.nebula.command.CommandManager;
|
import emu.nebula.command.CommandManager;
|
||||||
import emu.nebula.data.ResourceLoader;
|
import emu.nebula.data.ResourceLoader;
|
||||||
import emu.nebula.database.DatabaseManager;
|
import emu.nebula.database.DatabaseManager;
|
||||||
import emu.nebula.game.GameContext;
|
import emu.nebula.game.GameContext;
|
||||||
|
import emu.nebula.game.gacha.GachaDataMigration;
|
||||||
import emu.nebula.net.PacketHelper;
|
import emu.nebula.net.PacketHelper;
|
||||||
import emu.nebula.plugin.PluginManager;
|
import emu.nebula.plugin.PluginManager;
|
||||||
import emu.nebula.server.HttpServer;
|
import emu.nebula.server.HttpServer;
|
||||||
@@ -22,6 +14,13 @@ import emu.nebula.util.AeadHelper;
|
|||||||
import emu.nebula.util.Handbook;
|
import emu.nebula.util.Handbook;
|
||||||
import emu.nebula.util.JsonUtils;
|
import emu.nebula.util.JsonUtils;
|
||||||
import lombok.Getter;
|
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 {
|
public class Nebula {
|
||||||
private static final Logger log = LoggerFactory.getLogger(Nebula.class);
|
private static final Logger log = LoggerFactory.getLogger(Nebula.class);
|
||||||
@@ -48,12 +47,15 @@ public class Nebula {
|
|||||||
|
|
||||||
public static void main(String[] args) {
|
public static void main(String[] args) {
|
||||||
// Start Server
|
// Start Server
|
||||||
Nebula.getLogger().info("Starting Nebula " + getJarVersion());
|
Nebula.getLogger().info("Starting Nebula {}", getJarVersion());
|
||||||
Nebula.getLogger().info("Git hash: " + getGitHash());
|
Nebula.getLogger().info("Git hash: {}", getGitHash());
|
||||||
|
|
||||||
// Create data directory if it doesn't exist yet
|
// Create data directory if it doesn't exist yet
|
||||||
if (!dataDir.exists()) {
|
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
|
// Load config and data versions first
|
||||||
@@ -61,7 +63,7 @@ public class Nebula {
|
|||||||
Nebula.loadDataVersions();
|
Nebula.loadDataVersions();
|
||||||
|
|
||||||
// Output game version
|
// Output game version
|
||||||
Nebula.getLogger().info("Game version: " + GameConstants.getGameVersion());
|
Nebula.getLogger().info("Game version: {}", GameConstants.getGameVersion());
|
||||||
|
|
||||||
// Load keys
|
// Load keys
|
||||||
AeadHelper.loadKeys();
|
AeadHelper.loadKeys();
|
||||||
@@ -91,7 +93,7 @@ public class Nebula {
|
|||||||
case "-database":
|
case "-database":
|
||||||
// Database only
|
// Database only
|
||||||
DatabaseManager.startInternalMongoServer(Nebula.getConfig().getInternalMongoServer());
|
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;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -115,6 +117,10 @@ public class Nebula {
|
|||||||
Nebula.getLogger().error("Unable to start the database(s).", exception);
|
Nebula.getLogger().error("Unable to start the database(s).", exception);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (serverType.runGame() && Nebula.getGameDatabase() != null) {
|
||||||
|
GachaDataMigration.run();
|
||||||
|
}
|
||||||
|
|
||||||
// Start game context
|
// Start game context
|
||||||
Nebula.gameContext = new GameContext();
|
Nebula.gameContext = new GameContext();
|
||||||
Nebula.commandManager = new CommandManager();
|
Nebula.commandManager = new CommandManager();
|
||||||
|
|||||||
@@ -1,19 +1,7 @@
|
|||||||
package emu.nebula.data;
|
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.custom.CharGemAttrGroupDef;
|
||||||
import emu.nebula.data.resources.*;
|
import emu.nebula.data.resources.*;
|
||||||
|
|
||||||
import lombok.Getter;
|
import lombok.Getter;
|
||||||
|
|
||||||
@SuppressWarnings("unused")
|
@SuppressWarnings("unused")
|
||||||
@@ -87,8 +75,11 @@ public class GameData {
|
|||||||
@Getter private static DataTable<DictionaryEntryDef> DictionaryEntryDataTable = new DataTable<>();
|
@Getter private static DataTable<DictionaryEntryDef> DictionaryEntryDataTable = new DataTable<>();
|
||||||
|
|
||||||
// ===== Gacha =====
|
// ===== Gacha =====
|
||||||
|
@Getter private static DataTable<GachaATypeProbDef> GachaATypeProbDataTable = new DataTable<>();
|
||||||
@Getter private static DataTable<GachaDef> GachaDataTable = 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<GachaStorageDef> GachaStorageDataTable = new DataTable<>();
|
||||||
|
@Getter private static DataTable<GachaTypeDef> GachaTypeDataTable = new DataTable<>();
|
||||||
|
|
||||||
// ===== Story =====
|
// ===== Story =====
|
||||||
@Getter private static DataTable<StoryDef> StoryDataTable = new DataTable<>();
|
@Getter private static DataTable<StoryDef> StoryDataTable = new DataTable<>();
|
||||||
|
|||||||
@@ -94,11 +94,10 @@ public class ResourceLoader {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} 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
|
// Utility
|
||||||
@@ -132,7 +131,7 @@ public class ResourceLoader {
|
|||||||
field.setAccessible(true);
|
field.setAccessible(true);
|
||||||
table = (DataTable<T>) field.get(null);
|
table = (DataTable<T>) field.get(null);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
|
// ignore exception
|
||||||
} finally {
|
} finally {
|
||||||
field.setAccessible(false);
|
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;
|
package emu.nebula.data.resources;
|
||||||
|
|
||||||
|
import emu.nebula.Nebula;
|
||||||
import emu.nebula.data.BaseDef;
|
import emu.nebula.data.BaseDef;
|
||||||
import emu.nebula.data.GameData;
|
import emu.nebula.data.GameData;
|
||||||
import emu.nebula.data.ResourceType;
|
import emu.nebula.data.ResourceType;
|
||||||
|
import emu.nebula.data.ResourceType.LoadPriority;
|
||||||
|
import emu.nebula.util.Utils;
|
||||||
import emu.nebula.util.WeightedList;
|
import emu.nebula.util.WeightedList;
|
||||||
|
import it.unimi.dsi.fastutil.ints.IntArrayList;
|
||||||
|
import it.unimi.dsi.fastutil.ints.IntList;
|
||||||
import lombok.Getter;
|
import lombok.Getter;
|
||||||
|
|
||||||
@Getter
|
@Getter
|
||||||
@ResourceType(name = "Gacha.json")
|
@ResourceType(name = "Gacha.json", loadPriority = LoadPriority.LOWEST)
|
||||||
public class GachaDef extends BaseDef {
|
public class GachaDef extends BaseDef {
|
||||||
private int Id;
|
private int Id;
|
||||||
private int StorageId;
|
private int StorageId;
|
||||||
@@ -16,6 +21,14 @@ public class GachaDef extends BaseDef {
|
|||||||
private int GuaranteeTimes;
|
private int GuaranteeTimes;
|
||||||
private int GuaranteeTid;
|
private int GuaranteeTid;
|
||||||
private int GuaranteeQty;
|
private int GuaranteeQty;
|
||||||
|
private int ATypeGuaranteeTimes;
|
||||||
|
|
||||||
|
private int SpecificTid;
|
||||||
|
private int SpecificQty;
|
||||||
|
|
||||||
|
private int FirstTenShow;
|
||||||
|
private String StartTime;
|
||||||
|
private String EndTime;
|
||||||
|
|
||||||
// Packages
|
// Packages
|
||||||
private int ATypePkg;
|
private int ATypePkg;
|
||||||
@@ -31,6 +44,11 @@ public class GachaDef extends BaseDef {
|
|||||||
private transient WeightedList<GachaPackage> packageA;
|
private transient WeightedList<GachaPackage> packageA;
|
||||||
private transient WeightedList<GachaPackage> packageB;
|
private transient WeightedList<GachaPackage> packageB;
|
||||||
private transient WeightedList<GachaPackage> packageC;
|
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
|
@Override
|
||||||
public int getId() {
|
public int getId() {
|
||||||
@@ -45,14 +63,106 @@ public class GachaDef extends BaseDef {
|
|||||||
return GameData.getGachaStorageDataTable().get(this.getStorageId());
|
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
|
@Override
|
||||||
public void onLoad() {
|
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
|
// Get storage
|
||||||
var storage = this.getStorageData();
|
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
|
// Package A
|
||||||
this.packageA = new WeightedList<GachaPackage>();
|
|
||||||
|
|
||||||
if (this.ATypePkg > 0) {
|
if (this.ATypePkg > 0) {
|
||||||
packageA.add(
|
packageA.add(
|
||||||
10000 - storage.getATypeUpProb(),
|
10000 - storage.getATypeUpProb(),
|
||||||
@@ -67,8 +177,6 @@ public class GachaDef extends BaseDef {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Package B
|
// Package B
|
||||||
this.packageB = new WeightedList<GachaPackage>();
|
|
||||||
|
|
||||||
if (this.BTypePkg > 0) {
|
if (this.BTypePkg > 0) {
|
||||||
packageB.add(
|
packageB.add(
|
||||||
storage.getBTypeProb(),
|
storage.getBTypeProb(),
|
||||||
@@ -89,14 +197,16 @@ public class GachaDef extends BaseDef {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Package C
|
// Package C
|
||||||
this.packageC = new WeightedList<GachaPackage>();
|
|
||||||
|
|
||||||
if (this.CTypePkg > 0) {
|
if (this.CTypePkg > 0) {
|
||||||
packageC.add(
|
packageC.add(
|
||||||
10000,
|
10000,
|
||||||
new GachaPackage(GachaPackageType.C, this.CTypePkg)
|
new GachaPackage(GachaPackageType.C, this.CTypePkg)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!isSpinConfigValid()) {
|
||||||
|
markInvalid("spin package/probability composition is invalid");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Getter
|
@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.BaseDef;
|
||||||
import emu.nebula.data.ResourceType;
|
import emu.nebula.data.ResourceType;
|
||||||
import emu.nebula.data.ResourceType.LoadPriority;
|
import emu.nebula.data.ResourceType.LoadPriority;
|
||||||
|
import emu.nebula.game.inventory.ItemParamMap;
|
||||||
import lombok.Getter;
|
import lombok.Getter;
|
||||||
|
|
||||||
@Getter
|
@Getter
|
||||||
@@ -15,13 +16,29 @@ public class GachaStorageDef extends BaseDef {
|
|||||||
private int CostId;
|
private int CostId;
|
||||||
private int CostQty;
|
private int CostQty;
|
||||||
|
|
||||||
|
private int ATypeGroup;
|
||||||
|
private int AUpGuaranteeTimes;
|
||||||
private int ATypeUpProb;
|
private int ATypeUpProb;
|
||||||
|
private int ATypeUpShowProb;
|
||||||
|
|
||||||
private int BTypeProb;
|
private int BTypeProb;
|
||||||
|
private int BGuaranteeTimes;
|
||||||
private int BTypeUpProb;
|
private int BTypeUpProb;
|
||||||
|
private int BTypeUpShowProb;
|
||||||
private int BTypeGuaranteeProb;
|
private int BTypeGuaranteeProb;
|
||||||
|
|
||||||
|
private String GiveItems;
|
||||||
|
private transient ItemParamMap giveItemsMap = new ItemParamMap();
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public int getId() {
|
public int getId() {
|
||||||
return Id;
|
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;
|
package emu.nebula.database;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.stream.Stream;
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
import emu.nebula.Config.DatabaseInfo;
|
import emu.nebula.Config.DatabaseInfo;
|
||||||
@@ -30,6 +32,7 @@ import dev.morphia.mapping.MapperOptions;
|
|||||||
import dev.morphia.query.FindOptions;
|
import dev.morphia.query.FindOptions;
|
||||||
import dev.morphia.query.Sort;
|
import dev.morphia.query.Sort;
|
||||||
import dev.morphia.query.filters.Filters;
|
import dev.morphia.query.filters.Filters;
|
||||||
|
import dev.morphia.query.updates.UpdateOperator;
|
||||||
import dev.morphia.query.updates.UpdateOperators;
|
import dev.morphia.query.updates.UpdateOperators;
|
||||||
import lombok.Getter;
|
import lombok.Getter;
|
||||||
|
|
||||||
@@ -110,7 +113,7 @@ public final class DatabaseManager {
|
|||||||
ensureIndexes();
|
ensureIndexes();
|
||||||
|
|
||||||
// Done
|
// Done
|
||||||
Nebula.getLogger().info("Connected to the MongoDB database at " + connectionString);
|
Nebula.getLogger().info("Connected to the MongoDB database at {}", connectionString);
|
||||||
}
|
}
|
||||||
|
|
||||||
public MongoDatabase getDatabase() {
|
public MongoDatabase getDatabase() {
|
||||||
@@ -177,6 +180,30 @@ public final class DatabaseManager {
|
|||||||
.toList();
|
.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) {
|
public <T> void save(T obj) {
|
||||||
getDatastore().save(obj, INSERT_OPTIONS);
|
getDatastore().save(obj, INSERT_OPTIONS);
|
||||||
}
|
}
|
||||||
@@ -239,6 +266,20 @@ public final class DatabaseManager {
|
|||||||
.update(opt, UpdateOperators.addToSet(field, item));
|
.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
|
// Database counter
|
||||||
|
|
||||||
public synchronized int getNextObjectId(Class<?> c) {
|
public synchronized int getNextObjectId(Class<?> c) {
|
||||||
|
|||||||
@@ -2,23 +2,21 @@ package emu.nebula.game.gacha;
|
|||||||
|
|
||||||
import dev.morphia.annotations.Entity;
|
import dev.morphia.annotations.Entity;
|
||||||
|
|
||||||
|
import emu.nebula.data.GameData;
|
||||||
import emu.nebula.data.resources.GachaDef;
|
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.proto.GachaInformation.GachaInfo;
|
||||||
import emu.nebula.util.Utils;
|
|
||||||
|
|
||||||
import lombok.Getter;
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
@Getter
|
@Getter
|
||||||
|
@Setter
|
||||||
@Entity(useDiscriminator = false)
|
@Entity(useDiscriminator = false)
|
||||||
public class GachaBannerInfo {
|
public class GachaBannerInfo {
|
||||||
private int id;
|
private int id;
|
||||||
|
|
||||||
private int total;
|
private int total;
|
||||||
private int missTimesA;
|
private boolean usedFirstTen;
|
||||||
private int missTimesUpA;
|
|
||||||
private int missTimesB;
|
|
||||||
private boolean usedGuarantee;
|
private boolean usedGuarantee;
|
||||||
|
|
||||||
@Deprecated //Morphia only
|
@Deprecated //Morphia only
|
||||||
@@ -30,82 +28,53 @@ public class GachaBannerInfo {
|
|||||||
this.id = data.getId();
|
this.id = data.getId();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setUsedGuarantee(boolean value) {
|
public GachaBannerDraft copyForSpin() {
|
||||||
this.usedGuarantee = value;
|
return new GachaBannerDraft(this.total, this.usedFirstTen, this.usedGuarantee);
|
||||||
}
|
}
|
||||||
|
|
||||||
public int doPull(GachaDef data) {
|
public void overwriteFrom(GachaBannerDraft draft) {
|
||||||
// Pull chances
|
if (draft == null) {
|
||||||
int chanceA = 20; // 2%
|
return;
|
||||||
int chanceB = 100; // 8%
|
|
||||||
|
|
||||||
// 4 star pity
|
|
||||||
if (this.missTimesB >= 9) {
|
|
||||||
chanceB = 1000;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5 star pity
|
this.total = draft.total();
|
||||||
if (this.missTimesA >= 159) {
|
this.usedFirstTen = draft.usedFirstTen();
|
||||||
chanceA = 1000;
|
this.usedGuarantee = draft.usedGuarantee();
|
||||||
chanceB = 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add miss times
|
public GachaInfo toProto(GachaPityState pityState) {
|
||||||
this.missTimesB++;
|
int aupGuaranteeTimes = 0;
|
||||||
this.missTimesA++;
|
var showFirstTenBonusHintText = false;
|
||||||
//this.missTimesUpA++;
|
var gachaData = GameData.getGachaDataTable().get(this.getId());
|
||||||
|
var storageData = gachaData != null ? gachaData.getStorageData() : null;
|
||||||
// Get random
|
if (storageData != null) {
|
||||||
int random = Utils.randomRange(1, 1000);
|
aupGuaranteeTimes = gachaData.getDisplayAUpGuaranteeTimes();
|
||||||
GachaPackage gp = null;
|
showFirstTenBonusHintText = !storageData.getGiveItemsMap().isEmpty() && !this.isUsedFirstTen();
|
||||||
|
|
||||||
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
|
int missTimesUpA = 0;
|
||||||
if (gp == null) {
|
int missTimesA = 0;
|
||||||
return 0;
|
if (pityState != null) {
|
||||||
|
missTimesUpA = pityState.getMissTimesUpA();
|
||||||
|
missTimesA = pityState.getMissTimesA();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get package
|
return GachaInfo.newInstance()
|
||||||
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()
|
|
||||||
.setId(this.getId())
|
.setId(this.getId())
|
||||||
.setGachaTotalTimes(this.getTotal())
|
.setGachaTotalTimes(this.getTotal())
|
||||||
.setTotalTimes(this.getTotal())
|
.setTotalTimes(this.getTotal())
|
||||||
.setAupMissTimes(this.getMissTimesA())
|
.setAupMissTimes(missTimesUpA)
|
||||||
.setAMissTimes(this.getMissTimesA())
|
.setAMissTimes(missTimesA)
|
||||||
.setReveFirstTenReward(true)
|
.setAupGuaranteeTimes(aupGuaranteeTimes)
|
||||||
|
.setReveFirstTenReward(!showFirstTenBonusHintText)
|
||||||
.setRecvGuaranteeReward(this.isUsedGuarantee());
|
.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;
|
package emu.nebula.game.gacha;
|
||||||
|
|
||||||
import dev.morphia.annotations.Entity;
|
import dev.morphia.annotations.Entity;
|
||||||
|
import dev.morphia.annotations.Id;
|
||||||
|
import dev.morphia.annotations.Indexed;
|
||||||
import emu.nebula.Nebula;
|
import emu.nebula.Nebula;
|
||||||
import emu.nebula.proto.GachaHistoriesOuterClass.GachaHistory;
|
import emu.nebula.proto.GachaHistoriesOuterClass.GachaHistory;
|
||||||
import it.unimi.dsi.fastutil.ints.IntList;
|
import it.unimi.dsi.fastutil.ints.IntList;
|
||||||
import lombok.Getter;
|
import lombok.Getter;
|
||||||
|
import org.bson.types.ObjectId;
|
||||||
|
|
||||||
@Getter
|
@Getter
|
||||||
@Entity(value = "banner_info", useDiscriminator = false)
|
@Entity(value = "gacha_history", useDiscriminator = false)
|
||||||
public class GachaHistoryLog {
|
public class GachaHistoryLog {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
private ObjectId id;
|
||||||
|
|
||||||
|
@Indexed
|
||||||
|
private int playerUid;
|
||||||
|
|
||||||
|
@Indexed
|
||||||
private int type;
|
private int type;
|
||||||
|
|
||||||
private int gid;
|
private int gid;
|
||||||
|
|
||||||
|
@Indexed
|
||||||
private long time;
|
private long time;
|
||||||
|
|
||||||
private IntList ids;
|
private IntList ids;
|
||||||
|
|
||||||
@Deprecated // Morphia only
|
@Deprecated // Morphia only
|
||||||
@@ -19,7 +34,8 @@ 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.type = type;
|
||||||
this.gid = gachaId;
|
this.gid = gachaId;
|
||||||
this.time = Nebula.getCurrentServerTime();
|
this.time = Nebula.getCurrentServerTime();
|
||||||
@@ -27,7 +43,6 @@ public class GachaHistoryLog {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Proto
|
// Proto
|
||||||
|
|
||||||
public GachaHistory toProto() {
|
public GachaHistory toProto() {
|
||||||
var proto = GachaHistory.newInstance()
|
var proto = GachaHistory.newInstance()
|
||||||
.setGid(this.getGid())
|
.setGid(this.getGid())
|
||||||
|
|||||||
@@ -1,31 +1,36 @@
|
|||||||
package emu.nebula.game.gacha;
|
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.Entity;
|
||||||
import dev.morphia.annotations.Id;
|
import dev.morphia.annotations.Id;
|
||||||
import emu.nebula.Nebula;
|
import emu.nebula.Nebula;
|
||||||
import emu.nebula.data.GameData;
|
|
||||||
import emu.nebula.data.resources.GachaDef;
|
import emu.nebula.data.resources.GachaDef;
|
||||||
|
import emu.nebula.data.resources.GachaNewbieDef;
|
||||||
import emu.nebula.database.GameDatabaseObject;
|
import emu.nebula.database.GameDatabaseObject;
|
||||||
import emu.nebula.game.player.Player;
|
import emu.nebula.game.player.Player;
|
||||||
import emu.nebula.game.player.PlayerChangeInfo;
|
|
||||||
import emu.nebula.game.player.PlayerManager;
|
import emu.nebula.game.player.PlayerManager;
|
||||||
|
import it.unimi.dsi.fastutil.ints.IntList;
|
||||||
import lombok.Getter;
|
import lombok.Getter;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
@Getter
|
@Getter
|
||||||
@Entity(value = "gacha", useDiscriminator = false)
|
@Entity(value = "gacha", useDiscriminator = false)
|
||||||
public class GachaManager extends PlayerManager implements GameDatabaseObject {
|
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
|
@Id
|
||||||
private int uid;
|
private int uid;
|
||||||
|
|
||||||
private Map<Integer, GachaBannerInfo> banners;
|
private Map<Integer, GachaBannerInfo> banners = new HashMap<>();
|
||||||
private Map<Integer, List<GachaHistoryLog>> histories;
|
private Map<Integer, GachaPityState> pityStates = new HashMap<>();
|
||||||
|
private Map<Integer, NewbieGachaState> newbieStates = new HashMap<>();
|
||||||
|
private transient Set<Integer> lockedNewbieObtainIds = new HashSet<>();
|
||||||
|
|
||||||
@Deprecated // Morphia only
|
@Deprecated // Morphia only
|
||||||
public GachaManager() {
|
public GachaManager() {
|
||||||
@@ -37,14 +42,24 @@ public class GachaManager extends PlayerManager implements GameDatabaseObject {
|
|||||||
this.setPlayer(player);
|
this.setPlayer(player);
|
||||||
this.uid = player.getUid();
|
this.uid = player.getUid();
|
||||||
|
|
||||||
this.banners = new HashMap<>();
|
|
||||||
this.histories = new HashMap<>();
|
|
||||||
|
|
||||||
this.save();
|
this.save();
|
||||||
}
|
}
|
||||||
|
|
||||||
public synchronized Collection<GachaBannerInfo> getBannerInfos() {
|
public synchronized NewbieObtainLock lockNewbieObtain(int newbieId) {
|
||||||
return this.banners.values();
|
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) {
|
public synchronized GachaBannerInfo getBannerInfo(GachaDef gachaData) {
|
||||||
@@ -54,85 +69,106 @@ public class GachaManager extends PlayerManager implements GameDatabaseObject {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
public void saveBanner(GachaBannerInfo info) {
|
||||||
Nebula.getGameDatabase().update(
|
Nebula.getGameDatabase().update(
|
||||||
this,
|
this,
|
||||||
this.getPlayerUid(),
|
this.getPlayerUid(),
|
||||||
"banners." + info.getId(),
|
PATH_BANNERS + info.getId(),
|
||||||
info
|
info
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public PlayerChangeInfo recvGuarantee(int id) {
|
private final class NewbieObtainLockHandle implements NewbieObtainLock {
|
||||||
// Get banner info
|
private final int newbieId;
|
||||||
var info = this.banners.get(id);
|
private boolean closed;
|
||||||
if (info == null) {
|
|
||||||
return null;
|
private NewbieObtainLockHandle(int newbieId) {
|
||||||
|
this.newbieId = newbieId;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get banner data
|
@Override
|
||||||
var data = GameData.getGachaDataTable().get(id);
|
public void close() {
|
||||||
if (data == null) {
|
synchronized (GachaManager.this) {
|
||||||
return null;
|
if (this.closed) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if we have enough pulls for a guarantee
|
this.closed = true;
|
||||||
if (!data.canGuarantee() || info.getTotal() < data.getGuaranteeTimes()) {
|
unlockNewbieObtainInternal(this.newbieId);
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
package emu.nebula.game.gacha;
|
||||||
|
|
||||||
|
import emu.nebula.Nebula;
|
||||||
import emu.nebula.data.GameData;
|
import emu.nebula.data.GameData;
|
||||||
|
import emu.nebula.data.resources.GachaStorageDef;
|
||||||
import emu.nebula.game.GameContext;
|
import emu.nebula.game.GameContext;
|
||||||
import emu.nebula.game.GameContextModule;
|
import emu.nebula.game.GameContextModule;
|
||||||
import emu.nebula.game.achievement.AchievementCondition;
|
import emu.nebula.game.achievement.AchievementCondition;
|
||||||
import emu.nebula.game.inventory.ItemAcquireMap;
|
import emu.nebula.game.inventory.ItemAcquireMap;
|
||||||
import emu.nebula.game.inventory.ItemParamMap;
|
import emu.nebula.game.inventory.ItemParamMap;
|
||||||
import emu.nebula.game.inventory.ItemType;
|
|
||||||
import emu.nebula.game.player.Player;
|
import emu.nebula.game.player.Player;
|
||||||
import emu.nebula.game.player.PlayerChangeInfo;
|
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 it.unimi.dsi.fastutil.ints.IntArrayList;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
public class GachaModule extends GameContextModule {
|
public class GachaModule extends GameContextModule {
|
||||||
|
private final NewbieGachaModule newbieGachaModule = new NewbieGachaModule();
|
||||||
|
|
||||||
public GachaModule(GameContext context) {
|
public GachaModule(GameContext context) {
|
||||||
super(context);
|
super(context);
|
||||||
}
|
}
|
||||||
|
|
||||||
public GachaResult spin(Player player, int bannerId, int mode) {
|
public GachaResult spin(Player player, int bannerId, int amount) {
|
||||||
// Get pull count
|
var data = GameData.getGachaDataTable().get(bannerId);
|
||||||
int amount = mode == 2 ? 10 : 1;
|
if (data == null || !data.isActiveAt(Nebula.getCurrentServerTime())) {
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public PlayerChangeInfo recvGuarantee(Player player, int bannerId) {
|
||||||
|
var manager = player.getGachaManager();
|
||||||
|
synchronized (manager) {
|
||||||
|
var info = manager.findBannerInfo(bannerId);
|
||||||
|
if (info == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// Get banner data
|
|
||||||
var data = GameData.getGachaDataTable().get(bannerId);
|
var data = GameData.getGachaDataTable().get(bannerId);
|
||||||
if (data == null) {
|
if (data == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
var bannerStorage = data.getStorageData();
|
if (!data.isActiveAt(Nebula.getCurrentServerTime())) {
|
||||||
if (bannerStorage == null) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create change info
|
if (!data.canGuarantee() || info.getTotal() < data.getGuaranteeTimes()) {
|
||||||
var change = new PlayerChangeInfo();
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// Check if we have the materials to gacha TODO
|
if (info.isUsedGuarantee()) {
|
||||||
int costQty = player.getInventory().getItemCount(bannerStorage.getDefaultId());
|
return null;
|
||||||
int costReq = bannerStorage.getDefaultQty() * amount;
|
}
|
||||||
|
info.setUsedGuarantee(true);
|
||||||
|
|
||||||
if (costReq > costQty) {
|
manager.saveBanner(info);
|
||||||
// Not enough materials, check if we can convert
|
return player.getInventory().addItem(data.getGuaranteeTid(), data.getGuaranteeQty());
|
||||||
int convertQty = player.getInventory().getResourceCount(bannerStorage.getCostId());
|
}
|
||||||
int convertReq = bannerStorage.getCostQty() * (costReq - costQty);
|
}
|
||||||
|
|
||||||
// Check if we can buy pulls
|
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) {
|
if (convertReq > convertQty) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert to pull currency
|
|
||||||
player.getInventory().removeItem(bannerStorage.getCostId(), convertReq, change);
|
|
||||||
}
|
}
|
||||||
|
int consumeDefaultQty = Math.min(remainingDefaultCostReq, defaultQty);
|
||||||
|
|
||||||
// Consume pull currency
|
var pityDraft = pityState.copyForSpin();
|
||||||
player.getInventory().removeItem(bannerStorage.getDefaultId(), Math.min(costReq, costQty), change);
|
var bannerDraft = info.copyForSpin();
|
||||||
|
var bonusOutcome = buildBonusItems(storage, data.getFirstTenShow(), amount, bannerDraft);
|
||||||
// Get gacha banner info
|
bannerDraft = bonusOutcome.bannerDraft();
|
||||||
var info = player.getGachaManager().getBannerInfo(data);
|
|
||||||
|
|
||||||
// Do gacha
|
|
||||||
var results = new IntArrayList();
|
|
||||||
|
|
||||||
|
var cards = new IntArrayList(amount);
|
||||||
for (int i = 0; i < amount; i++) {
|
for (int i = 0; i < amount; i++) {
|
||||||
int id = info.doPull(data);
|
var pullOutcome = GachaRollEngine.pull(data, pityDraft);
|
||||||
if (id <= 0) continue;
|
if (pullOutcome == null || pullOutcome.itemId() <= 0) {
|
||||||
|
Nebula.getLogger().warn("Gacha roll produced invalid item. uid={}, bannerId={}", player.getUid(), data.getId());
|
||||||
results.add(id);
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Setup variables
|
pityDraft = pullOutcome.pityDraft();
|
||||||
var acquireItems = new ItemAcquireMap(player, results);
|
cards.add(pullOutcome.itemId());
|
||||||
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
|
bannerDraft = new GachaBannerDraft(
|
||||||
if (count > 0) {
|
bannerDraft.total() + amount,
|
||||||
var characterData = GameData.getCharacterDataTable().get(id);
|
bannerDraft.usedFirstTen(),
|
||||||
if (characterData == null) continue;
|
bannerDraft.usedGuarantee()
|
||||||
|
);
|
||||||
|
|
||||||
transformItemsSrc.add(id, count);
|
if (!GachaRewardResolver.isResolvable(cards)) {
|
||||||
transformItemsDst.add(characterData.getFragmentsId(), characterData.getTransformQty() * count);
|
Nebula.getLogger().warn("Gacha roll contains unresolved rewards. uid={}, bannerId={}", player.getUid(), data.getId());
|
||||||
transformItemsDst.add(24, 40 * count); // Expert permits
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add to count
|
var acquireItems = new ItemAcquireMap(player, cards);
|
||||||
characters += acquire.getCount();
|
var rewardPlan = GachaRewardResolver.resolve(acquireItems, bonusOutcome.bonusItems());
|
||||||
} else if (acquire.getType() == ItemType.Disc) {
|
if (rewardPlan == null) {
|
||||||
// Get add amount
|
return null;
|
||||||
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
|
return new SpinPlan(
|
||||||
int maxTransformCount = Math.max(6 - begin, 0);
|
bannerDraft,
|
||||||
int transformCount = Math.min(count, maxTransformCount);
|
pityDraft,
|
||||||
int extraCount = count - maxTransformCount;
|
cards,
|
||||||
|
rewardPlan,
|
||||||
// Transform
|
new CostPlan(data.getSpecificTid(), specificConsumeQty, convertReq, consumeDefaultQty)
|
||||||
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
|
private PlayerChangeInfo applySpin(Player player, GachaStorageDef storage, SpinPlan spinPlan) {
|
||||||
bonusItems.add(23, 100 * acquire.getCount());
|
var change = new PlayerChangeInfo();
|
||||||
} else {
|
var inventory = player.getInventory();
|
||||||
// Should never happen
|
|
||||||
bonusItems.add(id, acquire.getCount());
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add gold discs
|
GachaRewardResolver.apply(player, spinPlan.rewardPlan(), change);
|
||||||
bonusItems.add(602, 30 * acquire.getCount());
|
return change;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add transform items to extra items
|
private void persistSpin(GachaManager manager,
|
||||||
bonusItems.add(transformItemsDst); // Add transform items
|
GachaBannerInfo info,
|
||||||
|
GachaPityState pityState,
|
||||||
// Add extra items
|
emu.nebula.data.resources.GachaDef data,
|
||||||
player.getInventory().addItems(bonusItems, change);
|
SpinPlan spinPlan) {
|
||||||
|
info.overwriteFrom(spinPlan.bannerDraft());
|
||||||
// Add acquire/transform protos
|
pityState.overwriteFrom(spinPlan.pityDraft());
|
||||||
change.add(acquireItems.toProto());
|
manager.saveSpinState(info, data.getStorageId(), data.getId(), spinPlan.cards());
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
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.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 it.unimi.dsi.fastutil.ints.IntList;
|
||||||
import lombok.Getter;
|
import lombok.Getter;
|
||||||
|
|
||||||
@Getter
|
@Getter
|
||||||
public class GachaResult {
|
public class GachaResult {
|
||||||
private GachaBannerInfo info;
|
private GachaBannerInfo info;
|
||||||
|
private GachaPityDraft pityState;
|
||||||
private PlayerChangeInfo change;
|
private PlayerChangeInfo change;
|
||||||
private IntList cards;
|
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.info = info;
|
||||||
|
this.pityState = pityState;
|
||||||
this.change = change;
|
this.change = change;
|
||||||
this.cards = cards;
|
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),
|
ErrCondNotMet(119906),
|
||||||
ErrInsufficientWorldClass(119907),
|
ErrInsufficientWorldClass(119907),
|
||||||
ErrNoRewardsToReceive(119908),
|
ErrNoRewardsToReceive(119908),
|
||||||
ErrAlreayExists(119909),
|
ErrAlreadyExists(119909),
|
||||||
ErrExchangeNotSupported(119910),
|
ErrExchangeNotSupported(119910),
|
||||||
ErrRequestTooFrequent(119911),
|
ErrRequestTooFrequent(119911),
|
||||||
ErrAlreaydReceive(119912),
|
ErrAlreadyReceive(119912),
|
||||||
ErrLimit(119913),
|
ErrLimit(119913),
|
||||||
ErrDataUpdated(119914);
|
ErrDataUpdated(119914);
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package emu.nebula.server.handlers;
|
package emu.nebula.server.handlers;
|
||||||
|
|
||||||
|
import emu.nebula.Nebula;
|
||||||
import emu.nebula.net.NetHandler;
|
import emu.nebula.net.NetHandler;
|
||||||
import emu.nebula.net.NetMsgId;
|
import emu.nebula.net.NetMsgId;
|
||||||
import emu.nebula.proto.Public.UI32;
|
import emu.nebula.proto.Public.UI32;
|
||||||
@@ -14,8 +15,8 @@ public class HandlerGachaGuaranteeRewardReceiveReq extends NetHandler {
|
|||||||
// Parse req
|
// Parse req
|
||||||
var req = UI32.parseFrom(message);
|
var req = UI32.parseFrom(message);
|
||||||
|
|
||||||
// Recieve guaranteed reward
|
// Receive guaranteed reward
|
||||||
var change = session.getPlayer().getGachaManager().recvGuarantee(req.getValue());
|
var change = Nebula.getGameContext().getGachaModule().recvGuarantee(session.getPlayer(), req.getValue());
|
||||||
|
|
||||||
if (change == null) {
|
if (change == null) {
|
||||||
return session.encodeMsg(NetMsgId.gacha_guarantee_reward_receive_failed_ack);
|
return session.encodeMsg(NetMsgId.gacha_guarantee_reward_receive_failed_ack);
|
||||||
|
|||||||
@@ -1,30 +1,46 @@
|
|||||||
package emu.nebula.server.handlers;
|
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.NetHandler;
|
||||||
import emu.nebula.net.NetMsgId;
|
import emu.nebula.net.NetMsgId;
|
||||||
import emu.nebula.proto.GachaHistoriesOuterClass.GachaHistories;
|
import emu.nebula.proto.GachaHistoriesOuterClass.GachaHistories;
|
||||||
import emu.nebula.proto.Public.UI32;
|
import emu.nebula.proto.Public.UI32;
|
||||||
import emu.nebula.net.HandlerId;
|
|
||||||
import emu.nebula.net.GameSession;
|
|
||||||
|
|
||||||
@HandlerId(NetMsgId.gacha_histories_req)
|
@HandlerId(NetMsgId.gacha_histories_req)
|
||||||
public class HandlerGachaHistoriesReq extends NetHandler {
|
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
|
@Override
|
||||||
public byte[] handle(GameSession session, byte[] message) throws Exception {
|
public byte[] handle(GameSession session, byte[] message) throws Exception {
|
||||||
// Parse request
|
// Parse request
|
||||||
var req = UI32.parseFrom(message);
|
var req = UI32.parseFrom(message);
|
||||||
|
int requestedStorageId = req.getValue();
|
||||||
// Get history log
|
if (requestedStorageId <= 0 || GameData.getGachaStorageDataTable().get(requestedStorageId) == null) {
|
||||||
var list = session.getPlayer().getGachaManager().getHistories().get(req.getValue());
|
return session.encodeMsg(NetMsgId.gacha_histories_failed_ack);
|
||||||
|
|
||||||
// Build response
|
|
||||||
var rsp = GachaHistories.newInstance();
|
|
||||||
|
|
||||||
if (list != null) {
|
|
||||||
for (var log : list) {
|
|
||||||
rsp.addList(log.toProto());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
// Encode and send
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
package emu.nebula.server.handlers;
|
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.NetHandler;
|
||||||
import emu.nebula.net.NetMsgId;
|
import emu.nebula.net.NetMsgId;
|
||||||
import emu.nebula.proto.GachaInformation.GachaInformationResp;
|
import emu.nebula.proto.GachaInformation.GachaInformationResp;
|
||||||
import emu.nebula.net.HandlerId;
|
|
||||||
import emu.nebula.net.GameSession;
|
|
||||||
|
|
||||||
@HandlerId(NetMsgId.gacha_information_req)
|
@HandlerId(NetMsgId.gacha_information_req)
|
||||||
public class HandlerGachaInformationReq extends NetHandler {
|
public class HandlerGachaInformationReq extends NetHandler {
|
||||||
@@ -13,9 +16,21 @@ public class HandlerGachaInformationReq extends NetHandler {
|
|||||||
public byte[] handle(GameSession session, byte[] message) throws Exception {
|
public byte[] handle(GameSession session, byte[] message) throws Exception {
|
||||||
// Build response
|
// Build response
|
||||||
var rsp = GachaInformationResp.newInstance();
|
var rsp = GachaInformationResp.newInstance();
|
||||||
|
var manager = session.getPlayer().getGachaManager();
|
||||||
|
synchronized (manager) {
|
||||||
|
for (var data : GameData.getGachaDataTable().values()) {
|
||||||
|
if (!data.isActiveAt(Nebula.getCurrentServerTime())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
for (var bannerInfo : session.getPlayer().getGachaManager().getBannerInfos()) {
|
var bannerInfo = manager.findBannerInfo(data.getId());
|
||||||
rsp.addInformation(bannerInfo.toProto());
|
if (bannerInfo == null) {
|
||||||
|
bannerInfo = new GachaBannerInfo(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
var pityState = manager.findPityState(data.getStorageId());
|
||||||
|
rsp.addInformation(bannerInfo.toProto(pityState));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Encode and send
|
// Encode and send
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
package emu.nebula.server.handlers;
|
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.NetHandler;
|
||||||
import emu.nebula.net.NetMsgId;
|
import emu.nebula.net.NetMsgId;
|
||||||
import emu.nebula.proto.GachaNewbieInfoOuterClass.GachaNewbieInfo;
|
|
||||||
import emu.nebula.proto.GachaNewbieInfoOuterClass.GachaNewbieInfoResp;
|
import emu.nebula.proto.GachaNewbieInfoOuterClass.GachaNewbieInfoResp;
|
||||||
import emu.nebula.net.HandlerId;
|
|
||||||
import emu.nebula.net.GameSession;
|
|
||||||
|
|
||||||
@HandlerId(NetMsgId.gacha_newbie_info_req)
|
@HandlerId(NetMsgId.gacha_newbie_info_req)
|
||||||
public class HandlerGachaNewbieInfoReq extends NetHandler {
|
public class HandlerGachaNewbieInfoReq extends NetHandler {
|
||||||
@@ -13,13 +13,9 @@ public class HandlerGachaNewbieInfoReq extends NetHandler {
|
|||||||
@Override
|
@Override
|
||||||
public byte[] handle(GameSession session, byte[] message) throws Exception {
|
public byte[] handle(GameSession session, byte[] message) throws Exception {
|
||||||
var rsp = GachaNewbieInfoResp.newInstance();
|
var rsp = GachaNewbieInfoResp.newInstance();
|
||||||
|
for (var info : Nebula.getGameContext().getGachaModule().listNewbieInfos(session.getPlayer())) {
|
||||||
var info = GachaNewbieInfo.newInstance()
|
|
||||||
.setId(5)
|
|
||||||
.setReceive(true);
|
|
||||||
|
|
||||||
rsp.addList(info);
|
rsp.addList(info);
|
||||||
|
}
|
||||||
return session.encodeMsg(NetMsgId.gacha_newbie_info_succeed_ack, rsp);
|
return session.encodeMsg(NetMsgId.gacha_newbie_info_succeed_ack, rsp);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +1,28 @@
|
|||||||
package emu.nebula.server.handlers;
|
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.NetHandler;
|
||||||
import emu.nebula.net.NetMsgId;
|
import emu.nebula.net.NetMsgId;
|
||||||
import emu.nebula.proto.GachaNewbieObtain.GachaNewbieObtainReq;
|
import emu.nebula.proto.GachaNewbieObtain.GachaNewbieObtainReq;
|
||||||
import emu.nebula.net.HandlerId;
|
|
||||||
import emu.nebula.net.GameSession;
|
|
||||||
|
|
||||||
@HandlerId(NetMsgId.gacha_newbie_obtain_req)
|
@HandlerId(NetMsgId.gacha_newbie_obtain_req)
|
||||||
public class HandlerGachaNewbieObtainReq extends NetHandler {
|
public class HandlerGachaNewbieObtainReq extends NetHandler {
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public byte[] handle(GameSession session, byte[] message) throws Exception {
|
public byte[] handle(GameSession session, byte[] message) throws Exception {
|
||||||
@SuppressWarnings("unused")
|
|
||||||
var req = GachaNewbieObtainReq.parseFrom(message);
|
var req = GachaNewbieObtainReq.parseFrom(message);
|
||||||
|
var change = Nebula.getGameContext().getGachaModule().obtainNewbie(session.getPlayer(), req.getId(), req.getIdx());
|
||||||
// TODO
|
if (change == null) {
|
||||||
return session.encodeMsg(NetMsgId.gacha_newbie_obtain_failed_ack);
|
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;
|
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.NetHandler;
|
||||||
import emu.nebula.net.NetMsgId;
|
import emu.nebula.net.NetMsgId;
|
||||||
import emu.nebula.net.HandlerId;
|
import emu.nebula.proto.GachaNewbieSpin.GachaNewbieSpinResp;
|
||||||
import emu.nebula.net.GameSession;
|
import emu.nebula.proto.GachaSpin.GachaSpinReq;
|
||||||
|
|
||||||
@HandlerId(NetMsgId.gacha_newbie_spin_req)
|
@HandlerId(NetMsgId.gacha_newbie_spin_req)
|
||||||
public class HandlerGachaNewbieSpinReq extends NetHandler {
|
public class HandlerGachaNewbieSpinReq extends NetHandler {
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public byte[] handle(GameSession session, byte[] message) throws Exception {
|
public byte[] handle(GameSession session, byte[] message) throws Exception {
|
||||||
|
var req = GachaSpinReq.parseFrom(message);
|
||||||
|
Integer amount = GachaMode.getAmountByMode(req.getMode());
|
||||||
|
if (amount == null) {
|
||||||
return session.encodeMsg(NetMsgId.gacha_newbie_spin_failed_ack);
|
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;
|
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.NetHandler;
|
||||||
import emu.nebula.net.NetMsgId;
|
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.GachaSpinReq;
|
||||||
import emu.nebula.proto.GachaSpin.GachaSpinResp;
|
|
||||||
import emu.nebula.proto.Public.ItemTpl;
|
|
||||||
|
|
||||||
@HandlerId(NetMsgId.gacha_spin_req)
|
@HandlerId(NetMsgId.gacha_spin_req)
|
||||||
public class HandlerGachaSpinReq extends NetHandler {
|
public class HandlerGachaSpinReq extends NetHandler {
|
||||||
@@ -17,34 +16,24 @@ public class HandlerGachaSpinReq extends NetHandler {
|
|||||||
public byte[] handle(GameSession session, byte[] message) throws Exception {
|
public byte[] handle(GameSession session, byte[] message) throws Exception {
|
||||||
// Parse request
|
// Parse request
|
||||||
var req = GachaSpinReq.parseFrom(message);
|
var req = GachaSpinReq.parseFrom(message);
|
||||||
|
Integer amount = GachaMode.getAmountByMode(req.getMode());
|
||||||
|
if (amount == null) {
|
||||||
|
return session.encodeMsg(NetMsgId.gacha_spin_failed_ack);
|
||||||
|
}
|
||||||
|
|
||||||
// Do gacha
|
// Do gacha
|
||||||
var result = Nebula.getGameContext().getGachaModule().spin(
|
var result = Nebula.getGameContext().getGachaModule().spin(
|
||||||
session.getPlayer(),
|
session.getPlayer(),
|
||||||
req.getId(),
|
req.getId(),
|
||||||
req.getMode()
|
amount
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result == null) {
|
if (result == null) {
|
||||||
return session.encodeMsg(NetMsgId.gacha_spin_failed_ack);
|
return session.encodeMsg(NetMsgId.gacha_spin_failed_ack);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build response
|
var gachaData = GameData.getGachaDataTable().get(req.getId());
|
||||||
var rsp = GachaSpinResp.newInstance()
|
var rsp = result.toSpinResp(gachaData);
|
||||||
.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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Encode and send response
|
// Encode and send response
|
||||||
return session.encodeMsg(NetMsgId.gacha_spin_succeed_ack, rsp);
|
return session.encodeMsg(NetMsgId.gacha_spin_succeed_ack, rsp);
|
||||||
|
|||||||
Reference in New Issue
Block a user