Reimplement gacha (#32)

* Fix some grammar warning

* Reimplement gacha

* Update GachaResult#toSpinResp declaration

---------

Co-authored-by: Yostarcc <lubenwei7758258@gmail.com>
This commit is contained in:
Yostarcc
2026-05-05 21:49:21 -07:00
committed by GitHub
co-authored by Yostarcc
parent c285c878f1
commit 1316654c15
32 changed files with 1992 additions and 450 deletions
@@ -2,23 +2,21 @@ package emu.nebula.game.gacha;
import dev.morphia.annotations.Entity;
import emu.nebula.data.GameData;
import emu.nebula.data.resources.GachaDef;
import emu.nebula.data.resources.GachaDef.GachaPackage;
import emu.nebula.data.resources.GachaPkgDef;
import emu.nebula.proto.GachaInformation.GachaInfo;
import emu.nebula.util.Utils;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
@Entity(useDiscriminator = false)
public class GachaBannerInfo {
private int id;
private int total;
private int missTimesA;
private int missTimesUpA;
private int missTimesB;
private boolean usedFirstTen;
private boolean usedGuarantee;
@Deprecated //Morphia only
@@ -29,83 +27,54 @@ public class GachaBannerInfo {
public GachaBannerInfo(GachaDef data) {
this.id = data.getId();
}
public void setUsedGuarantee(boolean value) {
this.usedGuarantee = value;
}
public int doPull(GachaDef data) {
// Pull chances
int chanceA = 20; // 2%
int chanceB = 100; // 8%
// 4 star pity
if (this.missTimesB >= 9) {
chanceB = 1000;
}
// 5 star pity
if (this.missTimesA >= 159) {
chanceA = 1000;
chanceB = 0;
}
// Add miss times
this.missTimesB++;
this.missTimesA++;
//this.missTimesUpA++;
// Get random
int random = Utils.randomRange(1, 1000);
GachaPackage gp = null;
if (random <= chanceA) {
// Reset pity
this.missTimesA = 0;
// Get A package
gp = data.getPackageA().next();
} else if (random <= chanceB) {
// Add miss times
this.missTimesB = 0;
// Get B package
gp = data.getPackageB().next();
} else {
// Get C package
gp = data.getPackageC().next();
}
// Sanity check
if (gp == null) {
return 0;
}
// Get package
var pkg = GachaPkgDef.getPackageById(gp.getId());
if (pkg == null) {
return 0;
}
// Add total pulls
this.total++;
// Get random id
return pkg.next();
}
// Proto
public GachaInfo toProto() {
var proto = GachaInfo.newInstance()
public GachaBannerDraft copyForSpin() {
return new GachaBannerDraft(this.total, this.usedFirstTen, this.usedGuarantee);
}
public void overwriteFrom(GachaBannerDraft draft) {
if (draft == null) {
return;
}
this.total = draft.total();
this.usedFirstTen = draft.usedFirstTen();
this.usedGuarantee = draft.usedGuarantee();
}
public GachaInfo toProto(GachaPityState pityState) {
int aupGuaranteeTimes = 0;
var showFirstTenBonusHintText = false;
var gachaData = GameData.getGachaDataTable().get(this.getId());
var storageData = gachaData != null ? gachaData.getStorageData() : null;
if (storageData != null) {
aupGuaranteeTimes = gachaData.getDisplayAUpGuaranteeTimes();
showFirstTenBonusHintText = !storageData.getGiveItemsMap().isEmpty() && !this.isUsedFirstTen();
}
int missTimesUpA = 0;
int missTimesA = 0;
if (pityState != null) {
missTimesUpA = pityState.getMissTimesUpA();
missTimesA = pityState.getMissTimesA();
}
return GachaInfo.newInstance()
.setId(this.getId())
.setGachaTotalTimes(this.getTotal())
.setTotalTimes(this.getTotal())
.setAupMissTimes(this.getMissTimesA())
.setAMissTimes(this.getMissTimesA())
.setReveFirstTenReward(true)
.setAupMissTimes(missTimesUpA)
.setAMissTimes(missTimesA)
.setAupGuaranteeTimes(aupGuaranteeTimes)
.setReveFirstTenReward(!showFirstTenBonusHintText)
.setRecvGuaranteeReward(this.isUsedGuarantee());
return proto;
}
public record GachaBannerDraft(
int total,
boolean usedFirstTen,
boolean usedGuarantee
) {
}
}
@@ -0,0 +1,205 @@
package emu.nebula.game.gacha;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import emu.nebula.data.GameData;
import emu.nebula.util.JsonUtils;
import org.bson.Document;
import com.mongodb.client.model.Filters;
import com.mongodb.client.model.UpdateOneModel;
import com.mongodb.client.model.UpdateOptions;
import com.mongodb.client.model.Updates;
import emu.nebula.Nebula;
public final class GachaDataMigration {
private static final String TABLE_GACHA = "gacha";
private static final String TABLE_GACHA_HISTORY = "gacha_history";
private static final String FIELD_ID = "_id";
private static final String FIELD_PITY_STATES = "pityStates";
private static final String FIELD_HISTORIES = "histories";
private static final String FIELD_PLAYER_UID = "playerUid";
private static final String FIELD_TYPE = "type";
private static final String FIELD_GID = "gid";
private static final String FIELD_TIME = "time";
private static final String FIELD_IDS = "ids";
private static final String FIELD_MISS_TIMES_A = "missTimesA";
private static final String FIELD_MISS_TIMES_UP_A = "missTimesUpA";
private static final String FIELD_MISS_TIMES_B = "missTimesB";
@SuppressWarnings({"MismatchedQueryAndUpdateOfCollection"})
private static final class LegacyPlayerGachaDoc {
int _id;
Map<Integer, LegacyBannerState> banners;
Map<Integer, List<LegacyHistoryRow>> histories;
}
private static final class LegacyBannerState {
int id;
int missTimesA;
int missTimesUpA;
int missTimesB;
}
private static final class LegacyHistoryRow {
int type;
int gid;
long time;
List<Integer> ids = new ArrayList<>();
}
private static Document derivePityStatesFromBanners(Map<Integer, LegacyBannerState> banners) {
if (banners == null || banners.isEmpty()) {
return null;
}
var pityStates = new Document();
for (var entry : banners.entrySet()) {
var bannerState = entry.getValue();
if (bannerState == null) {
continue;
}
// Trust inner banner id; map key is treated as legacy container key.
int bannerId = bannerState.id;
if (bannerId <= 0) {
continue;
}
var data = GameData.getGachaDataTable().get(bannerId);
if (data == null) {
continue;
}
// New pity state key is storageId, so multiple banners may merge here.
String storageKey = String.valueOf(data.getStorageId());
var existing = pityStates.get(storageKey, Document.class);
int existingA = existing != null ? existing.getInteger(FIELD_MISS_TIMES_A, 0) : 0;
int existingUpA = existing != null ? existing.getInteger(FIELD_MISS_TIMES_UP_A, 0) : 0;
int existingB = existing != null ? existing.getInteger(FIELD_MISS_TIMES_B, 0) : 0;
// Keep the max counters per storageId to preserve the strongest pity progress.
int missTimesA = Math.max(existingA, bannerState.missTimesA);
int missTimesUpA = Math.max(existingUpA, bannerState.missTimesUpA);
int missTimesB = Math.max(existingB, bannerState.missTimesB);
pityStates.put(storageKey, new Document(FIELD_MISS_TIMES_A, missTimesA)
.append(FIELD_MISS_TIMES_UP_A, missTimesUpA)
.append(FIELD_MISS_TIMES_B, missTimesB));
}
return pityStates.isEmpty() ? null : pityStates;
}
// One-time startup migration for legacy gacha data.
// - Derive pityStates from banner-embedded pity counters.
// - Move embedded histories into a single table: gacha_history.
public static void run() {
// Collections: source player gacha state + target split history table.
var database = Nebula.getGameDatabase().getDatabase();
var gachaCollection = database.getCollection(TABLE_GACHA);
var historyCollection = database.getCollection(TABLE_GACHA_HISTORY);
// Query index for history browsing by player and pool type.
historyCollection.createIndex(new Document(FIELD_PLAYER_UID, 1).append(FIELD_TYPE, 1).append(FIELD_TIME, -1));
long scanned = 0;
long updated = 0;
long migratedHistoryAttempts = 0;
long migratedHistoryInserts = 0;
var migrationFilter = Filters.or(
Filters.exists(FIELD_HISTORIES),
Filters.and(Filters.exists("banners"), Filters.not(Filters.exists(FIELD_PITY_STATES)))
);
// Process only players that still have legacy fields to migrate.
for (var gachaDoc : gachaCollection.find(migrationFilter)) {
scanned++;
// Step 1: decode raw document into typed legacy DTO.
var legacy = JsonUtils.decode(gachaDoc.toJson(), LegacyPlayerGachaDoc.class);
if (legacy == null || legacy._id <= 0) {
continue;
}
int uid = legacy._id;
var ops = new ArrayList<org.bson.conversions.Bson>();
Object pityStates = gachaDoc.get(FIELD_PITY_STATES);
if (pityStates == null) {
// Step 2: derive storage-level pity states from legacy banners.
var derivedPityStates = derivePityStatesFromBanners(legacy.banners);
if (derivedPityStates != null) {
ops.add(Updates.set(FIELD_PITY_STATES, derivedPityStates));
}
}
if (legacy.histories != null && !legacy.histories.isEmpty()) {
var upserts = new ArrayList<UpdateOneModel<Document>>();
for (var typeEntry : legacy.histories.entrySet()) {
var historyRows = typeEntry.getValue();
if (historyRows == null) {
continue;
}
for (var row : historyRows) {
if (row == null || row.ids == null) {
continue;
}
int type = row.type;
var filter = Filters.and(
Filters.eq(FIELD_PLAYER_UID, uid),
Filters.eq(FIELD_TYPE, type),
Filters.eq(FIELD_GID, row.gid),
Filters.eq(FIELD_TIME, row.time),
Filters.eq(FIELD_IDS, row.ids)
);
// Step 3: split one legacy history row into gacha_history (idempotent upsert).
upserts.add(new UpdateOneModel<>(
filter,
Updates.combine(
Updates.setOnInsert(FIELD_PLAYER_UID, uid),
Updates.setOnInsert(FIELD_TYPE, type),
Updates.setOnInsert(FIELD_GID, row.gid),
Updates.setOnInsert(FIELD_TIME, row.time),
Updates.setOnInsert(FIELD_IDS, row.ids)
),
new UpdateOptions().upsert(true)
));
}
}
if (!upserts.isEmpty()) {
var result = historyCollection.bulkWrite(upserts);
migratedHistoryAttempts += upserts.size();
migratedHistoryInserts += result.getUpserts().size();
}
// Step 4: remove embedded legacy histories after split migration.
ops.add(Updates.unset(FIELD_HISTORIES));
}
if (!ops.isEmpty()) {
// Step 5: commit player-level migration changes.
gachaCollection.updateOne(Filters.eq(FIELD_ID, uid), Updates.combine(ops));
updated++;
}
}
Nebula.getLogger().info(
"Gacha migration completed. scanned={}, updated={}, historyAttempts={}, historyInserts={}",
scanned,
updated,
migratedHistoryAttempts,
migratedHistoryInserts
);
}
}
@@ -1,17 +1,32 @@
package emu.nebula.game.gacha;
import dev.morphia.annotations.Entity;
import dev.morphia.annotations.Id;
import dev.morphia.annotations.Indexed;
import emu.nebula.Nebula;
import emu.nebula.proto.GachaHistoriesOuterClass.GachaHistory;
import it.unimi.dsi.fastutil.ints.IntList;
import lombok.Getter;
import org.bson.types.ObjectId;
@Getter
@Entity(value = "banner_info", useDiscriminator = false)
@Entity(value = "gacha_history", useDiscriminator = false)
public class GachaHistoryLog {
@Id
private ObjectId id;
@Indexed
private int playerUid;
@Indexed
private int type;
private int gid;
@Indexed
private long time;
private IntList ids;
@Deprecated // Morphia only
@@ -19,15 +34,15 @@ public class GachaHistoryLog {
}
public GachaHistoryLog(int type, int gachaId, IntList results) {
public GachaHistoryLog(int playerUid, int type, int gachaId, IntList results) {
this.playerUid = playerUid;
this.type = type;
this.gid = gachaId;
this.time = Nebula.getCurrentServerTime();
this.ids = results;
}
// Proto
public GachaHistory toProto() {
var proto = GachaHistory.newInstance()
.setGid(this.getGid())
@@ -1,32 +1,37 @@
package emu.nebula.game.gacha;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import dev.morphia.annotations.Entity;
import dev.morphia.annotations.Id;
import emu.nebula.Nebula;
import emu.nebula.data.GameData;
import emu.nebula.data.resources.GachaDef;
import emu.nebula.data.resources.GachaNewbieDef;
import emu.nebula.database.GameDatabaseObject;
import emu.nebula.game.player.Player;
import emu.nebula.game.player.PlayerChangeInfo;
import emu.nebula.game.player.PlayerManager;
import it.unimi.dsi.fastutil.ints.IntList;
import lombok.Getter;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
@Getter
@Entity(value = "gacha", useDiscriminator = false)
public class GachaManager extends PlayerManager implements GameDatabaseObject {
private static final String PATH_BANNERS = "banners.";
private static final String PATH_PITY_STATES = "pityStates.";
private static final String PATH_NEWBIE_STATES = "newbieStates.";
private static final int HISTORY_SAVE_RETRIES = 2;
@Id
private int uid;
private Map<Integer, GachaBannerInfo> banners;
private Map<Integer, List<GachaHistoryLog>> histories;
private Map<Integer, GachaBannerInfo> banners = new HashMap<>();
private Map<Integer, GachaPityState> pityStates = new HashMap<>();
private Map<Integer, NewbieGachaState> newbieStates = new HashMap<>();
private transient Set<Integer> lockedNewbieObtainIds = new HashSet<>();
@Deprecated // Morphia only
public GachaManager() {
@@ -36,103 +41,134 @@ public class GachaManager extends PlayerManager implements GameDatabaseObject {
this();
this.setPlayer(player);
this.uid = player.getUid();
this.banners = new HashMap<>();
this.histories = new HashMap<>();
this.save();
}
public synchronized Collection<GachaBannerInfo> getBannerInfos() {
return this.banners.values();
public synchronized NewbieObtainLock lockNewbieObtain(int newbieId) {
if (this.lockedNewbieObtainIds.contains(newbieId)) {
return null;
}
this.lockedNewbieObtainIds.add(newbieId);
return new NewbieObtainLockHandle(newbieId);
}
public synchronized boolean isNewbieObtainLocked(int newbieId) {
return this.lockedNewbieObtainIds.contains(newbieId);
}
private synchronized void unlockNewbieObtainInternal(int newbieId) {
this.lockedNewbieObtainIds.remove(newbieId);
}
public synchronized GachaBannerInfo getBannerInfo(GachaDef gachaData) {
return this.banners.computeIfAbsent(
gachaData.getId(),
i -> new GachaBannerInfo(gachaData)
gachaData.getId(),
i -> new GachaBannerInfo(gachaData)
);
}
public synchronized GachaPityState getPityState(int storageId) {
return this.pityStates.computeIfAbsent(storageId, i -> new GachaPityState());
}
public synchronized GachaBannerInfo findBannerInfo(int bannerId) {
return this.banners.get(bannerId);
}
public synchronized GachaPityState findPityState(int storageId) {
return this.pityStates.get(storageId);
}
public synchronized void saveSpinState(GachaBannerInfo info, int storageId, int gachaId, IntList results) {
this.pityStates.computeIfAbsent(storageId, i -> new GachaPityState());
var updates = new HashMap<String, Object>();
updates.put(PATH_BANNERS + info.getId(), info);
updates.put(PATH_PITY_STATES + storageId, this.pityStates.get(storageId));
Nebula.getGameDatabase().update(this, this.getPlayerUid(), updates);
var log = new GachaHistoryLog(this.getPlayerUid(), storageId, gachaId, results);
Exception lastError = null;
for (int attempt = 1; attempt <= HISTORY_SAVE_RETRIES + 1; attempt++) {
try {
Nebula.getGameDatabase().save(log);
return;
} catch (Exception e) {
lastError = e;
}
}
Nebula.getLogger().warn(
"Failed to persist gacha history after retries. uid={}, type={}, gid={}, time={}",
log.getPlayerUid(),
log.getType(),
log.getGid(),
log.getTime(),
lastError
);
}
public synchronized NewbieGachaState getOrCreateNewbieState(GachaNewbieDef newbieDef) {
var state = this.newbieStates.get(newbieDef.getId());
if (state == null) {
state = new NewbieGachaState(newbieDef.getId(), newbieDef.getSpinCount(), newbieDef.getSaveCount());
this.newbieStates.put(newbieDef.getId(), state);
}
state.applyConfig(newbieDef.getSaveCount());
return state;
}
public synchronized NewbieGachaState findNewbieState(int newbieId) {
return this.newbieStates.get(newbieId);
}
public synchronized void saveNewbieState(NewbieGachaState state) {
this.newbieStates.put(state.getId(), state);
Nebula.getGameDatabase().update(
this,
this.getPlayerUid(),
PATH_NEWBIE_STATES + state.getId(),
state
);
}
public void saveBanner(GachaBannerInfo info) {
Nebula.getGameDatabase().update(
this,
this.getPlayerUid(),
"banners." + info.getId(),
info
this,
this.getPlayerUid(),
PATH_BANNERS + info.getId(),
info
);
}
public PlayerChangeInfo recvGuarantee(int id) {
// Get banner info
var info = this.banners.get(id);
if (info == null) {
return null;
}
// Get banner data
var data = GameData.getGachaDataTable().get(id);
if (data == null) {
return null;
}
// Check if we have enough pulls for a guarantee
if (!data.canGuarantee() || info.getTotal() < data.getGuaranteeTimes()) {
return null;
}
// Make sure we havent used our guarantee yet
if (info.isUsedGuarantee()) {
return null;
}
// Set guarantee
info.setUsedGuarantee(true);
// Update to database
this.saveBanner(info);
// Give player the guaranteed item
return getPlayer().getInventory().addItem(data.getGuaranteeTid(), data.getGuaranteeQty());
}
// Histories
public void addGachaHistory(GachaHistoryLog log) {
// Get history
var list = this.histories.computeIfAbsent(
log.getType(),
i -> new ArrayList<>()
);
// Add to history
list.add(log);
// Limit history
boolean resize = false;
while (list.size() > 50) {
list.remove(0);
resize = true;
private final class NewbieObtainLockHandle implements NewbieObtainLock {
private final int newbieId;
private boolean closed;
private NewbieObtainLockHandle(int newbieId) {
this.newbieId = newbieId;
}
// Update to database
if (resize) {
// Replace history logs
Nebula.getGameDatabase().update(
this,
this.getPlayerUid(),
"histories." + log.getType(),
list
);
} else {
// Add to history list
Nebula.getGameDatabase().addToSet(
this,
this.getPlayerUid(),
"histories." + log.getType(),
log
);
@Override
public void close() {
synchronized (GachaManager.this) {
if (this.closed) {
return;
}
this.closed = true;
unlockNewbieObtainInternal(this.newbieId);
}
}
}
public interface NewbieObtainLock extends AutoCloseable {
@Override
void close();
}
}
@@ -0,0 +1,38 @@
package emu.nebula.game.gacha;
import it.unimi.dsi.fastutil.ints.Int2ObjectMap;
import it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap;
import lombok.Getter;
@Getter
public enum GachaMode {
NEWBIE(0, 10),
SINGLE(1, 1),
TEN(2, 10);
private static final Int2ObjectMap<GachaMode> GACHA_MODES = new Int2ObjectOpenHashMap<>();
static {
for (var gachaMode : values()) {
GACHA_MODES.put(gachaMode.mode, gachaMode);
}
}
private final int mode;
private final int amount;
GachaMode(int mode, int amount) {
this.mode = mode;
this.amount = amount;
}
public static GachaMode getGachaMode(int mode) {
return GACHA_MODES.get(mode);
}
public static Integer getAmountByMode(int mode) {
var gachaMode = getGachaMode(mode);
return gachaMode != null ? gachaMode.amount : null;
}
}
@@ -1,181 +1,253 @@
package emu.nebula.game.gacha;
import emu.nebula.Nebula;
import emu.nebula.data.GameData;
import emu.nebula.data.resources.GachaStorageDef;
import emu.nebula.game.GameContext;
import emu.nebula.game.GameContextModule;
import emu.nebula.game.achievement.AchievementCondition;
import emu.nebula.game.inventory.ItemAcquireMap;
import emu.nebula.game.inventory.ItemParamMap;
import emu.nebula.game.inventory.ItemType;
import emu.nebula.game.player.Player;
import emu.nebula.game.player.PlayerChangeInfo;
import emu.nebula.proto.Public.Transform;
import emu.nebula.game.gacha.GachaBannerInfo.GachaBannerDraft;
import emu.nebula.game.gacha.GachaPityState.GachaPityDraft;
import emu.nebula.proto.GachaNewbieInfoOuterClass.GachaNewbieInfo;
import it.unimi.dsi.fastutil.ints.IntArrayList;
import java.util.List;
public class GachaModule extends GameContextModule {
private final NewbieGachaModule newbieGachaModule = new NewbieGachaModule();
public GachaModule(GameContext context) {
super(context);
}
public GachaResult spin(Player player, int bannerId, int mode) {
// Get pull count
int amount = mode == 2 ? 10 : 1;
// Get banner data
public GachaResult spin(Player player, int bannerId, int amount) {
var data = GameData.getGachaDataTable().get(bannerId);
if (data == null) {
if (data == null || !data.isActiveAt(Nebula.getCurrentServerTime())) {
return null;
}
var bannerStorage = data.getStorageData();
if (bannerStorage == null) {
return null;
var storage = data.getStorageData();
var manager = player.getGachaManager();
synchronized (manager) {
var info = manager.getBannerInfo(data);
var pityState = manager.getPityState(data.getStorageId());
var spinPlan = prepareSpin(player, data, storage, amount, info, pityState);
if (spinPlan == null) {
return null;
}
var change = applySpin(player, storage, spinPlan);
persistSpin(manager, info, pityState, data, spinPlan);
player.trigger(AchievementCondition.GachaTotal, amount);
player.trigger(AchievementCondition.GachaCharacterTotal, spinPlan.rewardPlan().characterCount());
return new GachaResult(info, spinPlan.pityDraft(), change, spinPlan.cards());
}
// Create change info
var change = new PlayerChangeInfo();
// Check if we have the materials to gacha TODO
int costQty = player.getInventory().getItemCount(bannerStorage.getDefaultId());
int costReq = bannerStorage.getDefaultQty() * amount;
if (costReq > costQty) {
// Not enough materials, check if we can convert
int convertQty = player.getInventory().getResourceCount(bannerStorage.getCostId());
int convertReq = bannerStorage.getCostQty() * (costReq - costQty);
// Check if we can buy pulls
}
public PlayerChangeInfo recvGuarantee(Player player, int bannerId) {
var manager = player.getGachaManager();
synchronized (manager) {
var info = manager.findBannerInfo(bannerId);
if (info == null) {
return null;
}
var data = GameData.getGachaDataTable().get(bannerId);
if (data == null) {
return null;
}
if (!data.isActiveAt(Nebula.getCurrentServerTime())) {
return null;
}
if (!data.canGuarantee() || info.getTotal() < data.getGuaranteeTimes()) {
return null;
}
if (info.isUsedGuarantee()) {
return null;
}
info.setUsedGuarantee(true);
manager.saveBanner(info);
return player.getInventory().addItem(data.getGuaranteeTid(), data.getGuaranteeQty());
}
}
public List<GachaNewbieInfo> listNewbieInfos(Player player) {
return this.newbieGachaModule.listInfos(player);
}
public int[] spinNewbie(Player player, int newbieId) {
return this.newbieGachaModule.spin(player, newbieId);
}
public boolean saveNewbie(Player player, int newbieId, Integer index) {
return this.newbieGachaModule.save(player, newbieId, index);
}
public PlayerChangeInfo obtainNewbie(Player player, int newbieId, int index) {
return this.newbieGachaModule.obtain(player, newbieId, index);
}
private BonusItemsOutcome buildBonusItems(GachaStorageDef storage,
int firstTenMultiplier,
int amount,
GachaBannerDraft snapshot) {
var bonusItems = new ItemParamMap();
var giveItemsMap = storage.getGiveItemsMap();
if (giveItemsMap == null || giveItemsMap.isEmpty()) {
return new BonusItemsOutcome(bonusItems, snapshot);
}
int multiplier = 1;
var draft = snapshot;
if (amount == GachaMode.TEN.getAmount() && !snapshot.usedFirstTen()) {
multiplier = Math.max(1, firstTenMultiplier);
draft = new GachaBannerDraft(snapshot.total(), true, snapshot.usedGuarantee());
}
for (var entry : giveItemsMap.entries()) {
bonusItems.add(entry.getIntKey(), entry.getIntValue() * multiplier * amount);
}
return new BonusItemsOutcome(bonusItems, draft);
}
private record BonusItemsOutcome(
ItemParamMap bonusItems,
GachaBannerDraft bannerDraft
) {
}
private SpinPlan prepareSpin(Player player,
emu.nebula.data.resources.GachaDef data,
GachaStorageDef storage,
int amount,
GachaBannerInfo info,
GachaPityState pityState) {
var inventory = player.getInventory();
int specificConsumeQty = resolveSpecificConsumeQty(inventory, data, amount);
int coveredPullCount = specificConsumeQty > 0 && data.getSpecificQty() > 0
? specificConsumeQty / data.getSpecificQty()
: 0;
int remainingDefaultCostReq = storage.getDefaultQty() * Math.max(amount - coveredPullCount, 0);
int defaultQty = inventory.getItemCount(storage.getDefaultId());
int convertReq = 0;
if (remainingDefaultCostReq > defaultQty) {
int convertQty = inventory.getResourceCount(storage.getCostId());
convertReq = storage.getCostQty() * (remainingDefaultCostReq - defaultQty);
if (convertReq > convertQty) {
return null;
}
// Convert to pull currency
player.getInventory().removeItem(bannerStorage.getCostId(), convertReq, change);
}
// Consume pull currency
player.getInventory().removeItem(bannerStorage.getDefaultId(), Math.min(costReq, costQty), change);
// Get gacha banner info
var info = player.getGachaManager().getBannerInfo(data);
// Do gacha
var results = new IntArrayList();
int consumeDefaultQty = Math.min(remainingDefaultCostReq, defaultQty);
var pityDraft = pityState.copyForSpin();
var bannerDraft = info.copyForSpin();
var bonusOutcome = buildBonusItems(storage, data.getFirstTenShow(), amount, bannerDraft);
bannerDraft = bonusOutcome.bannerDraft();
var cards = new IntArrayList(amount);
for (int i = 0; i < amount; i++) {
int id = info.doPull(data);
if (id <= 0) continue;
results.add(id);
}
// Setup variables
var acquireItems = new ItemAcquireMap(player, results);
var transformItemsSrc = new ItemParamMap();
var transformItemsDst = new ItemParamMap();
var bonusItems = new ItemParamMap();
// Character count (for achievements)
int characters = 0;
// Add for player
for (var entry : acquireItems.getItems().int2ObjectEntrySet()) {
// Get ids and aquire params
int id = entry.getIntKey();
var acquire = entry.getValue();
// Add to player
if (acquire.getType() == ItemType.Char) {
// Get add amount
int count = acquire.getCount();
// Add char to player
if (acquire.getBegin() == 0) {
player.getInventory().addItem(id, 1, change);
count--;
}
// Talent material
if (count > 0) {
var characterData = GameData.getCharacterDataTable().get(id);
if (characterData == null) continue;
transformItemsSrc.add(id, count);
transformItemsDst.add(characterData.getFragmentsId(), characterData.getTransformQty() * count);
transformItemsDst.add(24, 40 * count); // Expert permits
}
// Add to count
characters += acquire.getCount();
} else if (acquire.getType() == ItemType.Disc) {
// Get add amount
int begin = acquire.getBegin();
int count = acquire.getCount();
// Add disc to player
if (begin == 0) {
player.getInventory().addItem(id, 1, change);
count--;
begin++;
}
// Talent material
int maxTransformCount = Math.max(6 - begin, 0);
int transformCount = Math.min(count, maxTransformCount);
int extraCount = count - maxTransformCount;
// Transform
if (transformCount > 0) {
var discData = GameData.getDiscDataTable().get(id);
if (discData == null) continue;
// Star material
transformItemsSrc.add(id, transformCount);
transformItemsDst.add(discData.getTransformItemId(), transformCount);
} else if (extraCount > 0) {
// Permit
transformItemsSrc.add(id, extraCount);
transformItemsDst.add(23, 100 * extraCount);
}
// Add Travel permits
bonusItems.add(23, 100 * acquire.getCount());
} else {
// Should never happen
bonusItems.add(id, acquire.getCount());
var pullOutcome = GachaRollEngine.pull(data, pityDraft);
if (pullOutcome == null || pullOutcome.itemId() <= 0) {
Nebula.getLogger().warn("Gacha roll produced invalid item. uid={}, bannerId={}", player.getUid(), data.getId());
return null;
}
// Add gold discs
bonusItems.add(602, 30 * acquire.getCount());
pityDraft = pullOutcome.pityDraft();
cards.add(pullOutcome.itemId());
}
// Add transform items to extra items
bonusItems.add(transformItemsDst); // Add transform items
// Add extra items
player.getInventory().addItems(bonusItems, change);
// Add acquire/transform protos
change.add(acquireItems.toProto());
var transform = Transform.newInstance();
transformItemsSrc.toItemTemplateStream().forEach(transform::addSrc);
transformItemsDst.toItemTemplateStream().forEach(transform::addDst);
change.add(transform);
// Save banner info to database
player.getGachaManager().saveBanner(info);
// Add history
var log = new GachaHistoryLog(data.getStorageId(), data.getId(), results);
player.getGachaManager().addGachaHistory(log);
// Trigger achievements
player.trigger(AchievementCondition.GachaTotal, amount);
player.trigger(AchievementCondition.GachaCharacterTotal, characters);
// Complete
return new GachaResult(info, change, results);
bannerDraft = new GachaBannerDraft(
bannerDraft.total() + amount,
bannerDraft.usedFirstTen(),
bannerDraft.usedGuarantee()
);
if (!GachaRewardResolver.isResolvable(cards)) {
Nebula.getLogger().warn("Gacha roll contains unresolved rewards. uid={}, bannerId={}", player.getUid(), data.getId());
return null;
}
var acquireItems = new ItemAcquireMap(player, cards);
var rewardPlan = GachaRewardResolver.resolve(acquireItems, bonusOutcome.bonusItems());
if (rewardPlan == null) {
return null;
}
return new SpinPlan(
bannerDraft,
pityDraft,
cards,
rewardPlan,
new CostPlan(data.getSpecificTid(), specificConsumeQty, convertReq, consumeDefaultQty)
);
}
private PlayerChangeInfo applySpin(Player player, GachaStorageDef storage, SpinPlan spinPlan) {
var change = new PlayerChangeInfo();
var inventory = player.getInventory();
if (spinPlan.costPlan().consumeSpecificQty() > 0) {
inventory.removeItem(spinPlan.costPlan().specificItemId(), spinPlan.costPlan().consumeSpecificQty(), change);
}
if (spinPlan.costPlan().convertReq() > 0) {
inventory.removeItem(storage.getCostId(), spinPlan.costPlan().convertReq(), change);
}
if (spinPlan.costPlan().consumeDefaultQty() > 0) {
inventory.removeItem(storage.getDefaultId(), spinPlan.costPlan().consumeDefaultQty(), change);
}
GachaRewardResolver.apply(player, spinPlan.rewardPlan(), change);
return change;
}
private void persistSpin(GachaManager manager,
GachaBannerInfo info,
GachaPityState pityState,
emu.nebula.data.resources.GachaDef data,
SpinPlan spinPlan) {
info.overwriteFrom(spinPlan.bannerDraft());
pityState.overwriteFrom(spinPlan.pityDraft());
manager.saveSpinState(info, data.getStorageId(), data.getId(), spinPlan.cards());
}
private int resolveSpecificConsumeQty(emu.nebula.game.inventory.Inventory inventory,
emu.nebula.data.resources.GachaDef data,
int amount) {
if (data.getSpecificTid() <= 0 || data.getSpecificQty() <= 0) {
return 0;
}
int specificReq = data.getSpecificQty() * amount;
int specificQty = inventory.getItemCount(data.getSpecificTid());
return Math.min(specificReq, specificQty);
}
private record CostPlan(
int specificItemId,
int consumeSpecificQty,
int convertReq,
int consumeDefaultQty
) {
}
private record SpinPlan(
GachaBannerDraft bannerDraft,
GachaPityDraft pityDraft,
IntArrayList cards,
GachaRewardResolver.RewardPlan rewardPlan,
CostPlan costPlan
) {
}
}
@@ -0,0 +1,40 @@
package emu.nebula.game.gacha;
import dev.morphia.annotations.Entity;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
@Entity(useDiscriminator = false)
public class GachaPityState {
private int missTimesA;
private int missTimesUpA;
private int missTimesB;
private boolean bGuaranteeDebt;
public GachaPityDraft copyForSpin() {
return new GachaPityDraft(this.missTimesA, this.missTimesUpA, this.missTimesB, this.bGuaranteeDebt);
}
public void overwriteFrom(GachaPityDraft draft) {
if (draft == null) {
return;
}
this.missTimesA = draft.missTimesA();
this.missTimesUpA = draft.missTimesUpA();
this.missTimesB = draft.missTimesB();
this.bGuaranteeDebt = draft.bGuaranteeDebt();
}
public record GachaPityDraft(
int missTimesA,
int missTimesUpA,
int missTimesB,
boolean bGuaranteeDebt
) {
}
}
@@ -1,19 +1,48 @@
package emu.nebula.game.gacha;
import emu.nebula.Nebula;
import emu.nebula.data.resources.GachaDef;
import emu.nebula.game.player.PlayerChangeInfo;
import emu.nebula.game.gacha.GachaPityState.GachaPityDraft;
import emu.nebula.proto.GachaSpin.GachaCard;
import emu.nebula.proto.GachaSpin.GachaSpinResp;
import emu.nebula.proto.Public.ItemTpl;
import it.unimi.dsi.fastutil.ints.IntList;
import lombok.Getter;
@Getter
public class GachaResult {
private GachaBannerInfo info;
private GachaPityDraft pityState;
private PlayerChangeInfo change;
private IntList cards;
public GachaResult(GachaBannerInfo info, PlayerChangeInfo change, IntList cards) {
public GachaResult(GachaBannerInfo info, GachaPityDraft pityState, PlayerChangeInfo change, IntList cards) {
this.info = info;
this.pityState = pityState;
this.change = change;
this.cards = cards;
}
public GachaSpinResp toSpinResp(GachaDef gachaData) {
int aupGuaranteeTimes = gachaData != null ? gachaData.getDisplayAUpGuaranteeTimes() : 0;
var rsp = GachaSpinResp.newInstance()
.setTime(Nebula.getCurrentServerTime())
.setAMissTimes(this.pityState.missTimesA())
.setAupMissTimes(this.pityState.missTimesUpA())
.setTotalTimes(this.info.getTotal())
.setGachaTotalTimes(this.info.getTotal())
.setAupGuaranteeTimes(aupGuaranteeTimes)
.setChange(this.change.toProto());
for (int id : this.cards) {
var card = GachaCard.newInstance()
.setCard(ItemTpl.newInstance().setTid(id).setQty(1));
rsp.addCards(card);
}
return rsp;
}
}
@@ -0,0 +1,194 @@
package emu.nebula.game.gacha;
import emu.nebula.data.GameData;
import emu.nebula.game.inventory.ItemAcquireMap;
import emu.nebula.game.inventory.ItemParamMap;
import emu.nebula.game.inventory.ItemType;
import emu.nebula.game.player.Player;
import emu.nebula.game.player.PlayerChangeInfo;
import emu.nebula.proto.Public.Transform;
import it.unimi.dsi.fastutil.ints.IntList;
import java.util.function.IntPredicate;
public final class GachaRewardResolver {
private static final int MAX_DISC_COUNT = 6;
private static final int ITEM_ID_TRAVEL_PERMIT = 23;
private static final int ITEM_ID_EXPERT_PERMIT = 24;
private static final int EXPERT_PERMIT_PER_DUP_CHAR = 40;
private static final int TRAVEL_PERMIT_PER_DISC = 100;
private GachaRewardResolver() {
}
public static boolean isResolvable(IntList cards) {
if (cards == null || cards.isEmpty()) {
return false;
}
return isResolvable(consumer -> {
for (int id : cards) {
if (!consumer.test(id)) {
return false;
}
}
return true;
});
}
public static boolean isResolvable(int[] cards) {
if (cards == null || cards.length == 0) {
return false;
}
return isResolvable(consumer -> {
for (int id : cards) {
if (!consumer.test(id)) {
return false;
}
}
return true;
});
}
private static boolean isResolvable(IntPredicateRunner runner) {
return runner.run(GachaRewardResolver::isResolvableItem);
}
private static boolean isResolvableItem(int itemId) {
var itemData = GameData.getItemDataTable().get(itemId);
if (itemData == null) {
return false;
}
if (itemData.getItemType() == ItemType.Char) {
var characterData = GameData.getCharacterDataTable().get(itemId);
return characterData != null;
}
if (itemData.getItemType() == ItemType.Disc) {
var discData = GameData.getDiscDataTable().get(itemId);
return discData != null;
}
return true;
}
public static RewardPlan resolve(ItemAcquireMap acquireItems, ItemParamMap baseBonusItems) {
if (acquireItems == null) {
return null;
}
var grantItems = new ItemParamMap();
if (baseBonusItems != null) {
grantItems.add(baseBonusItems);
}
var transformSrcItems = new ItemParamMap();
var transformDstItems = new ItemParamMap();
int characters = 0;
for (var entry : acquireItems.getItems().int2ObjectEntrySet()) {
int id = entry.getIntKey();
var acquire = entry.getValue();
if (acquire.getType() == ItemType.Char) {
int count = acquire.getCount();
int newCharacterCount = acquire.getBegin() == 0 ? 1 : 0;
int duplicateCount = Math.max(count - newCharacterCount, 0);
if (duplicateCount > 0) {
var characterData = GameData.getCharacterDataTable().get(id);
if (characterData == null) {
continue;
}
transformSrcItems.add(id, duplicateCount);
transformDstItems.add(characterData.getFragmentsId(), characterData.getTransformQty() * duplicateCount);
transformDstItems.add(ITEM_ID_EXPERT_PERMIT, EXPERT_PERMIT_PER_DUP_CHAR * duplicateCount);
}
if (newCharacterCount > 0) {
grantItems.add(id, newCharacterCount);
}
characters += acquire.getCount();
} else if (acquire.getType() == ItemType.Disc) {
int begin = acquire.getBegin();
int count = acquire.getCount();
int newDiscCount = begin == 0 ? 1 : 0;
int duplicateCount = Math.max(count - newDiscCount, 0);
int effectiveBegin = begin + newDiscCount;
int maxTransformCount = Math.max(MAX_DISC_COUNT - effectiveBegin, 0);
int transformCount = Math.min(duplicateCount, maxTransformCount);
int extraCount = Math.max(duplicateCount - transformCount, 0);
if (transformCount > 0) {
var discData = GameData.getDiscDataTable().get(id);
if (discData == null) {
continue;
}
transformSrcItems.add(id, transformCount);
transformDstItems.add(discData.getTransformItemId(), transformCount);
}
if (extraCount > 0) {
transformSrcItems.add(id, extraCount);
transformDstItems.add(ITEM_ID_TRAVEL_PERMIT, TRAVEL_PERMIT_PER_DISC * extraCount);
}
if (newDiscCount > 0) {
grantItems.add(id, newDiscCount);
}
grantItems.add(ITEM_ID_TRAVEL_PERMIT, TRAVEL_PERMIT_PER_DISC * acquire.getCount());
} else {
grantItems.add(id, acquire.getCount());
}
}
grantItems.add(transformDstItems);
return new RewardPlan(
acquireItems,
grantItems,
new TransformLog(transformSrcItems, transformDstItems),
characters
);
}
public static int apply(Player player, RewardPlan plan, PlayerChangeInfo change) {
if (player == null || plan == null || change == null) {
return 0;
}
player.getInventory().addItems(plan.grantItems(), change);
change.add(plan.acquireItems().toProto());
var transform = Transform.newInstance();
plan.transformLog().srcItems().toItemTemplateStream().forEach(transform::addSrc);
plan.transformLog().dstItems().toItemTemplateStream().forEach(transform::addDst);
change.add(transform);
return plan.characterCount();
}
@FunctionalInterface
private interface IntPredicateRunner {
boolean run(IntPredicate predicate);
}
public record RewardPlan(
ItemAcquireMap acquireItems,
ItemParamMap grantItems,
TransformLog transformLog,
int characterCount
) {
}
public record TransformLog(
ItemParamMap srcItems,
ItemParamMap dstItems
) {
}
}
@@ -0,0 +1,167 @@
package emu.nebula.game.gacha;
import emu.nebula.data.resources.GachaATypeProbDef;
import emu.nebula.data.resources.GachaDef;
import emu.nebula.data.resources.GachaDef.GachaPackage;
import emu.nebula.data.resources.GachaPkgDef;
import emu.nebula.game.gacha.GachaPityState.GachaPityDraft;
import emu.nebula.util.WeightedList;
import emu.nebula.util.Utils;
public final class GachaRollEngine {
private static final int PROBABILITY_BASE = 10000;
private static final int DEFAULT_A_TYPE_PROB = 200;
private static final int DEFAULT_B_GUARANTEE_TIMES = 10;
private record Rates(int rollBase, int chanceA, int chanceB, int aupGuaranteeTimes, int bGuaranteeTimes) {}
private static Rates resolveRates(GachaDef data, int missTimesA) {
int rollBase = Math.max(PROBABILITY_BASE, GachaATypeProbDef.getMaxProb());
int aupGuaranteeTimes = 0;
int chanceA = 0;
int chanceB = 0;
int bGuaranteeTimes = 0;
var storageData = data.getStorageData();
if (storageData != null) {
aupGuaranteeTimes = storageData.getAUpGuaranteeTimes();
chanceA = GachaATypeProbDef.getProb(storageData.getATypeGroup(), missTimesA, DEFAULT_A_TYPE_PROB);
chanceB = storageData.getBTypeProb();
bGuaranteeTimes = storageData.getBGuaranteeTimes();
if (bGuaranteeTimes <= 0) {
bGuaranteeTimes = DEFAULT_B_GUARANTEE_TIMES;
}
if (aupGuaranteeTimes > 0 && missTimesA >= aupGuaranteeTimes - 1) {
chanceA = rollBase;
}
}
if (chanceA >= rollBase) {
chanceA = rollBase;
chanceB = 0;
}
return new Rates(rollBase, chanceA, chanceB, aupGuaranteeTimes, bGuaranteeTimes);
}
private static GachaPackage choosePackage(GachaDef data, int random, int chanceA, int chanceB, boolean forceAUp) {
if (forceAUp) {
if (data.getATypeUpPkg() > 0) {
return new GachaPackage(GachaDef.GachaPackageType.A_UP, data.getATypeUpPkg());
}
return safeNext(data.getPackageA());
}
if (random <= chanceA) {
return safeNext(data.getPackageA());
}
if (random <= chanceB) {
return safeNext(data.getPackageB());
}
return safeNext(data.getPackageC());
}
private static GachaPackage safeNext(WeightedList<GachaPackage> list) {
if (list == null || list.size() == 0) {
return null;
}
return list.next();
}
private static RollOutcome roll(GachaDef data, int missTimesA, int missTimesUpA, int missTimesB, boolean bGuaranteeDebt) {
Rates rates = resolveRates(data, missTimesA);
boolean forceAUp = rates.aupGuaranteeTimes > 0 && missTimesUpA >= rates.aupGuaranteeTimes - 1;
int chanceA = rates.chanceA;
int chanceB = rates.chanceB;
boolean bGuaranteeTriggered = false;
if (rates.bGuaranteeTimes > 0 && missTimesB >= rates.bGuaranteeTimes - 1) {
chanceB = rates.rollBase;
bGuaranteeTriggered = true;
}
int newMissTimesA = missTimesA + 1;
int newMissTimesB = missTimesB + 1;
int newMissTimesUpA = missTimesUpA;
int random = Utils.randomRange(1, rates.rollBase);
GachaPackage gachaPackage = choosePackage(data, random, chanceA, chanceB, forceAUp);
boolean newBGuaranteeDebt = bGuaranteeDebt;
if (forceAUp || random <= chanceA) {
newMissTimesA = 0;
if (bGuaranteeTriggered) {
newBGuaranteeDebt = true;
}
} else if (random <= chanceB) {
newMissTimesB = 0;
newBGuaranteeDebt = false;
}
if (newBGuaranteeDebt && gachaPackage != null && gachaPackage.getType() == GachaDef.GachaPackageType.C) {
var compensated = safeNext(data.getPackageB());
if (compensated != null) {
gachaPackage = compensated;
newMissTimesB = 0;
newBGuaranteeDebt = false;
}
}
if (gachaPackage != null && gachaPackage.getType() == GachaDef.GachaPackageType.A_UP) {
newMissTimesUpA = 0;
} else {
newMissTimesUpA++;
}
return new RollOutcome(gachaPackage, newMissTimesA, newMissTimesUpA, newMissTimesB, newBGuaranteeDebt);
}
public static PullOutcome pull(GachaDef data, GachaPityDraft pityDraft) {
if (data == null || pityDraft == null) {
return null;
}
var outcome = roll(data, pityDraft.missTimesA(), pityDraft.missTimesUpA(),
pityDraft.missTimesB(), pityDraft.bGuaranteeDebt());
var gachaPackage = outcome.gachaPackage();
if (gachaPackage == null) {
return null;
}
var pkg = GachaPkgDef.getPackageById(gachaPackage.getId());
if (pkg == null) {
return null;
}
return new PullOutcome(
pkg.next(),
new GachaPityDraft(
outcome.missTimesA(),
outcome.missTimesUpA(),
outcome.missTimesB(),
outcome.bGuaranteeDebt()
)
);
}
public record RollOutcome(
GachaPackage gachaPackage,
int missTimesA,
int missTimesUpA,
int missTimesB,
boolean bGuaranteeDebt
) {}
public record PullOutcome(
int itemId,
GachaPityDraft pityDraft
) {
}
}
@@ -0,0 +1,212 @@
package emu.nebula.game.gacha;
import emu.nebula.data.GameData;
import emu.nebula.data.resources.GachaNewbieDef;
import emu.nebula.game.inventory.ItemAcquireMap;
import emu.nebula.game.player.Player;
import emu.nebula.game.player.PlayerChangeInfo;
import emu.nebula.proto.GachaNewbieInfoOuterClass.GachaNewbieInfo;
import emu.nebula.proto.GachaNewbieInfoOuterClass.UI32s;
import it.unimi.dsi.fastutil.ints.IntArrayList;
import java.util.ArrayList;
import java.util.List;
public final class NewbieGachaModule {
private record NewbieRequest(GachaManager manager, GachaNewbieDef newbieDef) {
}
public List<GachaNewbieInfo> listInfos(Player player) {
var newbieDefs = GameData.getGachaNewbieDataTable().values();
var infos = new ArrayList<GachaNewbieInfo>(newbieDefs.size());
var manager = player.getGachaManager();
synchronized (manager) {
for (var data : newbieDefs) {
var state = manager.getOrCreateNewbieState(data);
boolean received = state.isReceived();
int usedSpinCount = Math.max(0, data.getSpinCount() - state.getRemainingSpinCount());
var info = GachaNewbieInfo.newInstance()
.setId(data.getId())
.setTimes(usedSpinCount)
.setReceive(received);
if (!received) {
var pendingResult = state.getPendingResult();
if (pendingResult != null) {
info.getMutableTemp().addAllValues(pendingResult);
}
for (var cards : state.getSavedResults()) {
if (cards != null && cards.length > 0) {
info.addCards(UI32s.newInstance().addAllValues(cards));
}
}
}
infos.add(info);
}
}
return infos;
}
public int[] spin(Player player, int newbieId) {
var request = resolveRequest(player, newbieId);
if (request == null) {
return null;
}
int newbieStateId = request.newbieDef().getId();
var bannerDef = GameData.getGachaDataTable().get(newbieStateId);
if (bannerDef == null) {
return null;
}
var manager = request.manager();
synchronized (manager) {
var state = loadStateForSpin(manager, request.newbieDef());
if (state == null) {
return null;
}
int[] cards = NewbieRollStrategy.rollTenPull(bannerDef, NewbieRollStrategy.defaultProfile());
if (!GachaRewardResolver.isResolvable(cards)) {
return null;
}
if (!state.applySpinResult(cards)) {
return null;
}
manager.saveNewbieState(state);
return cards;
}
}
public boolean save(Player player, int newbieId, Integer index) {
int resolvedIndex = index == null ? -1 : index;
var request = resolveRequest(player, newbieId);
if (request == null) {
return false;
}
var manager = request.manager();
synchronized (manager) {
var state = loadStateForSave(manager, request.newbieDef());
if (state == null) {
return false;
}
if (!state.savePendingResult(resolvedIndex)) {
return false;
}
manager.saveNewbieState(state);
return true;
}
}
public PlayerChangeInfo obtain(Player player, int newbieId, int index) {
if (index < 0) {
return null;
}
var request = resolveRequest(player, newbieId);
if (request == null) {
return null;
}
int newbieStateId = request.newbieDef().getId();
var obtainLock = request.manager().lockNewbieObtain(newbieStateId);
if (obtainLock == null) {
return null;
}
try (var ignored = obtainLock) {
NewbieGachaState state;
int[] cards;
synchronized (request.manager()) {
state = loadStateForObtain(request.manager(), request.newbieDef(), index);
if (state == null) {
return null;
}
cards = state.copySavedResult(index);
if (!GachaRewardResolver.isResolvable(cards)) {
return null;
}
}
var change = new PlayerChangeInfo();
var acquireItems = new ItemAcquireMap(player, new IntArrayList(cards));
var rewardPlan = GachaRewardResolver.resolve(acquireItems, null);
if (rewardPlan == null) {
return null;
}
synchronized (request.manager()) {
if (!state.markReceived(index)) {
return null;
}
request.manager().saveNewbieState(state);
}
GachaRewardResolver.apply(player, rewardPlan, change);
return change;
}
}
private NewbieRequest resolveRequest(Player player, int newbieId) {
var newbieDef = GameData.getGachaNewbieDataTable().get(newbieId);
if (newbieDef == null) {
return null;
}
return new NewbieRequest(player.getGachaManager(), newbieDef);
}
private NewbieGachaState loadStateForSpin(GachaManager manager, GachaNewbieDef newbieDef) {
int newbieStateId = newbieDef.getId();
if (manager.isNewbieObtainLocked(newbieStateId)) {
return null;
}
var state = manager.getOrCreateNewbieState(newbieDef);
if (!state.canSpin(false)) {
return null;
}
return state;
}
private NewbieGachaState loadStateForSave(GachaManager manager,
GachaNewbieDef newbieDef) {
int newbieStateId = newbieDef.getId();
if (manager.isNewbieObtainLocked(newbieStateId)) {
return null;
}
var state = manager.getOrCreateNewbieState(newbieDef);
if (!state.canSavePendingResult(false)) {
return null;
}
return state;
}
private NewbieGachaState loadStateForObtain(GachaManager manager, GachaNewbieDef newbieDef, int index) {
int newbieStateId = newbieDef.getId();
var state = manager.findNewbieState(newbieStateId);
if (state == null) {
return null;
}
if (!state.canObtain(index)) {
return null;
}
return state;
}
}
@@ -0,0 +1,101 @@
package emu.nebula.game.gacha;
import java.util.*;
import dev.morphia.annotations.Entity;
import lombok.Getter;
@Getter
@Entity(useDiscriminator = false)
public class NewbieGachaState {
private int id;
private int remainingSpinCount;
private int saveCount = 1;
private int selectedResult = -1;
private boolean received;
private int[] pendingResult;
private final List<int[]> savedResults = new ArrayList<>();
@Deprecated
public NewbieGachaState() {
}
public NewbieGachaState(int id, int spinCount, int saveCount) {
this.id = id;
this.remainingSpinCount = Math.max(0, spinCount);
this.saveCount = Math.max(1, saveCount);
}
public boolean hasPendingResult() {
return pendingResult != null && pendingResult.length > 0;
}
// Checks if the player can perform a spin.
public boolean canSpin(boolean obtainLocked) {
return !received && !obtainLocked && remainingSpinCount > 0;
}
// Checks if the current pending result can be moved to saved results
public boolean canSavePendingResult(boolean obtainLocked) {
return !received && !obtainLocked && hasPendingResult();
}
// Updates the maximum allowed saved results.
public boolean applyConfig(int saveCount) {
int oldSaveCount = this.saveCount;
this.saveCount = Math.max(1, saveCount);
return this.saveCount != oldSaveCount;
}
// Applies a new spin result to the pending slot and consumes a spin attempt
public boolean applySpinResult(int[] cards) {
if (!canSpin(false) || cards == null || cards.length == 0) {
return false;
}
this.pendingResult = cards;
this.remainingSpinCount--;
return true;
}
// Saves the pending result into the saved results list at the specified index or adds it
public boolean savePendingResult(int index) {
if (!hasPendingResult() || received) return false;
if (index >= 0 && index < savedResults.size()) {
// Replace existing slot
savedResults.set(index, pendingResult);
} else if (savedResults.size() < saveCount) {
// Add new slot if capacity allows
savedResults.add(pendingResult);
} else {
return false;
}
this.pendingResult = null;
return true;
}
// Returns a clone of the saved result at the given index
public int[] copySavedResult(int index) {
return (index >= 0 && index < savedResults.size()) ? savedResults.get(index).clone() : null;
}
// Check for claiming a specific result
public boolean canObtain(int index) {
return !received && index >= 0 && index < savedResults.size();
}
// Marks a specific saved result as claimed and closes the gacha session
public boolean markReceived(int index) {
if (received || index < 0 || index >= savedResults.size()) {
return false;
}
this.selectedResult = index;
this.received = true;
this.remainingSpinCount = 0;
this.pendingResult = null;
return true;
}
}
@@ -0,0 +1,121 @@
package emu.nebula.game.gacha;
import java.util.Collections;
import java.util.concurrent.ThreadLocalRandom;
import emu.nebula.data.resources.GachaDef;
import emu.nebula.data.resources.GachaPkgDef;
import emu.nebula.util.WeightedList;
import it.unimi.dsi.fastutil.ints.IntArrayList;
public final class NewbieRollStrategy {
private static final int TEN_PULL_COUNT = 10;
private static final int MAX_FIVE_COUNT = 1;
private static final int MIN_FOUR_COUNT = 1;
private static final int MAX_FOUR_COUNT_WITH_FIVE = 2;
private static final int MAX_FOUR_COUNT_WITHOUT_FIVE = 3;
private static final double DEFAULT_HAS_FIVE_RATE = 0.75;
// Chance to upgrade 4-star count from 1 to multi-4 (2 or 3).
private static final double DEFAULT_MULTI_FOUR_WHEN_FIVE_RATE = 10d / 15d;
// In multi-4 results, chance to output 3 four-stars instead of 2.
private static final double DEFAULT_THREE_FOUR_WHEN_MULTI_RATE = 0.35;
/**
* Newbie 10-pull shape profile:
* - hasFiveRate: chance that this 10-pull contains exactly one 5-star
* - multiFourWhenFiveRate: chance to upgrade 4-star count from 1 to multi-4
* - threeFourWhenMultiRate: in no-5-star multi-4 results, chance of 3 four-stars (else 2)
*/
public record Profile(double hasFiveRate, double multiFourWhenFiveRate, double threeFourWhenMultiRate) {}
private static final Profile DEFAULT_PROFILE = new Profile(
DEFAULT_HAS_FIVE_RATE,
DEFAULT_MULTI_FOUR_WHEN_FIVE_RATE,
DEFAULT_THREE_FOUR_WHEN_MULTI_RATE
);
public static Profile defaultProfile() {
return DEFAULT_PROFILE;
}
private record PullCounts(int fiveCount, int fourCount, int threeCount) {}
private static PullCounts resolvePullCounts(ThreadLocalRandom random, Profile profile) {
// Rule 1: a newbie 10-pull contains at most one 5-star
boolean hasFiveStar = random.nextDouble() < profile.hasFiveRate();
int fiveCount = hasFiveStar ? MAX_FIVE_COUNT : 0;
int fourCount = MIN_FOUR_COUNT;
if (hasFiveStar) {
// With a 5-star present, 4-star count is constrained to 1~2
fourCount = random.nextDouble() < profile.multiFourWhenFiveRate() ? MAX_FOUR_COUNT_WITH_FIVE : MIN_FOUR_COUNT;
} else {
// Without a 5-star, still guarantee at least one 4-star and allow up to 3
if (random.nextDouble() < profile.multiFourWhenFiveRate()) {
boolean rollThreeFours = random.nextDouble() < profile.threeFourWhenMultiRate();
fourCount = rollThreeFours ? MAX_FOUR_COUNT_WITHOUT_FIVE : 2;
}
}
int threeCount = TEN_PULL_COUNT - fiveCount - fourCount;
return new PullCounts(fiveCount, fourCount, threeCount);
}
private static int rollFromPackage(GachaDef.GachaPackage gachaPackage) {
if (gachaPackage == null) {
return 0;
}
var pkg = GachaPkgDef.getPackageById(gachaPackage.getId());
if (pkg == null || pkg.size() == 0) {
return 0;
}
return pkg.next();
}
private static boolean appendCardsFromTier(IntArrayList cards, WeightedList<GachaDef.GachaPackage> tier, int count) {
for (int i = 0; i < count; i++) {
int cardId = rollFromPackage(tier.next());
if (cardId <= 0) {
return false;
}
cards.add(cardId);
}
return true;
}
public static int[] rollTenPull(GachaDef bannerData, Profile profile) {
if (bannerData == null) {
return null;
}
var packageA = bannerData.getPackageA();
var packageB = bannerData.getPackageB();
var packageC = bannerData.getPackageC();
if (packageA == null || packageA.size() == 0 || packageB == null || packageB.size() == 0 || packageC == null || packageC.size() == 0) {
return null;
}
var random = ThreadLocalRandom.current();
// Step 1: decide rarity counts for this newbie 10-pull
PullCounts counts = resolvePullCounts(random, profile);
// Step 2: draw concrete item ids from A/B/C packages
var cards = new IntArrayList(TEN_PULL_COUNT);
if (!appendCardsFromTier(cards, packageA, counts.fiveCount())
|| !appendCardsFromTier(cards, packageB, counts.fourCount())
|| !appendCardsFromTier(cards, packageC, counts.threeCount())
|| cards.size() != TEN_PULL_COUNT) {
return null;
}
// Step 3: shuffle cards order
Collections.shuffle(cards, random);
return cards.toIntArray();
}
}
@@ -177,10 +177,10 @@ public enum PlayerErrorCode {
ErrCondNotMet(119906),
ErrInsufficientWorldClass(119907),
ErrNoRewardsToReceive(119908),
ErrAlreayExists(119909),
ErrAlreadyExists(119909),
ErrExchangeNotSupported(119910),
ErrRequestTooFrequent(119911),
ErrAlreaydReceive(119912),
ErrAlreadyReceive(119912),
ErrLimit(119913),
ErrDataUpdated(119914);