mirror of
https://github.com/Melledy/Nebula.git
synced 2026-09-20 01:29:54 +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:
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user