GachaManager.java 15.4 KB
Newer Older
Melledy's avatar
Melledy committed
1
2
package emu.grasscutter.game.gacha;

3
import java.io.File;
Melledy's avatar
Melledy committed
4
import java.io.FileReader;
5
6
import java.io.InputStreamReader;
import java.io.Reader;
7
import java.nio.file.*;
Melledy's avatar
Melledy committed
8
import java.util.ArrayList;
9
import java.util.Arrays;
Melledy's avatar
Melledy committed
10
11
12
13
14
15
import java.util.Collection;
import java.util.List;
import java.util.concurrent.ThreadLocalRandom;

import com.google.gson.reflect.TypeToken;

16
import com.sun.nio.file.SensitivityWatchEventModifier;
Melledy's avatar
Melledy committed
17
import emu.grasscutter.Grasscutter;
18
import emu.grasscutter.data.DataLoader;
19
import emu.grasscutter.data.GameData;
20
import emu.grasscutter.data.common.ItemParamData;
Melledy's avatar
Melledy committed
21
import emu.grasscutter.data.def.ItemData;
22
import emu.grasscutter.database.DatabaseHelper;
23
24
import emu.grasscutter.game.avatar.Avatar;
import emu.grasscutter.game.inventory.GameItem;
25
import emu.grasscutter.game.inventory.Inventory;
Melledy's avatar
Melledy committed
26
27
import emu.grasscutter.game.inventory.ItemType;
import emu.grasscutter.game.inventory.MaterialType;
Melledy's avatar
Melledy committed
28
import emu.grasscutter.game.player.Player;
Melledy's avatar
Melledy committed
29
30
31
32
33
import emu.grasscutter.net.proto.GachaItemOuterClass.GachaItem;
import emu.grasscutter.net.proto.GachaTransferItemOuterClass.GachaTransferItem;
import emu.grasscutter.net.proto.GetGachaInfoRspOuterClass.GetGachaInfoRsp;
import emu.grasscutter.net.proto.ItemParamOuterClass.ItemParam;
import emu.grasscutter.server.game.GameServer;
34
import emu.grasscutter.server.game.GameServerTickEvent;
Melledy's avatar
Melledy committed
35
import emu.grasscutter.server.packet.send.PacketDoGachaRsp;
AnimeGitB's avatar
AnimeGitB committed
36
import emu.grasscutter.utils.Utils;
Melledy's avatar
Melledy committed
37
38
39
40
import it.unimi.dsi.fastutil.ints.Int2ObjectMap;
import it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap;
import it.unimi.dsi.fastutil.ints.IntArrayList;
import it.unimi.dsi.fastutil.ints.IntList;
41
42
import org.greenrobot.eventbus.Subscribe;

43
44
import static emu.grasscutter.Configuration.*;

Melledy's avatar
Melledy committed
45
46
47
48
public class GachaManager {
	private final GameServer server;
	private final Int2ObjectMap<GachaBanner> gachaBanners;
	private GetGachaInfoRsp cachedProto;
49
	WatchService watchService;
Melledy's avatar
Melledy committed
50
	
51
52
	private static final int starglitterId = 221;
	private static final int stardustId = 222;
53
54
	private int[] fallbackItems4Pool2Default = {11401, 11402, 11403, 11405, 12401, 12402, 12403, 12405, 13401, 13407, 14401, 14402, 14403, 14409, 15401, 15402, 15403, 15405};
	private int[] fallbackItems5Pool2Default = {11501, 11502, 12501, 12502, 13502, 13505, 14501, 14502, 15501, 15502};
55

Melledy's avatar
Melledy committed
56
57
58
59
	public GachaManager(GameServer server) {
		this.server = server;
		this.gachaBanners = new Int2ObjectOpenHashMap<>();
		this.load();
60
		this.startWatcher(server);
Melledy's avatar
Melledy committed
61
62
63
64
65
66
67
68
69
	}

	public GameServer getServer() {
		return server;
	}

	public Int2ObjectMap<GachaBanner> getGachaBanners() {
		return gachaBanners;
	}
AnimeGitB's avatar
AnimeGitB committed
70
71
	
	public int randomRange(int min, int max) {  // Both are inclusive
Melledy's avatar
Melledy committed
72
73
74
75
76
77
78
79
		return ThreadLocalRandom.current().nextInt(max - min + 1) + min;
	}
	
	public int getRandom(int[] array) {
		return array[randomRange(0, array.length - 1)];
	}
	
	public synchronized void load() {
80
		try (Reader fileReader = new InputStreamReader(DataLoader.load("Banners.json"))) {
81
			getGachaBanners().clear();
Melledy's avatar
Melledy committed
82
			List<GachaBanner> banners = Grasscutter.getGsonFactory().fromJson(fileReader, TypeToken.getParameterized(Collection.class, GachaBanner.class).getType());
83
84
85
86
87
			if(banners.size() > 0) {
				for (GachaBanner banner : banners) {
					getGachaBanners().put(banner.getGachaType(), banner);
				}
				Grasscutter.getLogger().info("Banners successfully loaded.");
AnimeGitB's avatar
AnimeGitB committed
88
89


90
91
92
				this.cachedProto = createProto();
			} else {
				Grasscutter.getLogger().error("Unable to load banners. Banners size is 0.");
Melledy's avatar
Melledy committed
93
94
95
96
97
98
			}
		} catch (Exception e) {
			// TODO Auto-generated catch block
			e.printStackTrace();
		}
	}
AnimeGitB's avatar
AnimeGitB committed
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164

	private class BannerPools {
		public int[] rateUpItems4;
		public int[] rateUpItems5;
		public int[] fallbackItems4Pool1;
		public int[] fallbackItems4Pool2;
		public int[] fallbackItems5Pool1;
		public int[] fallbackItems5Pool2;

		public BannerPools(GachaBanner banner) {
			rateUpItems4 = banner.getRateUpItems4();
			rateUpItems5 = banner.getRateUpItems5();
			fallbackItems4Pool1 = banner.getFallbackItems4Pool1();
			fallbackItems4Pool2 = banner.getFallbackItems4Pool2();
			fallbackItems5Pool1 = banner.getFallbackItems5Pool1();
			fallbackItems5Pool2 = banner.getFallbackItems5Pool2();

			if (banner.getAutoStripRateUpFromFallback()) {
				fallbackItems4Pool1 = Utils.setSubtract(fallbackItems4Pool1, rateUpItems4);
				fallbackItems4Pool2 = Utils.setSubtract(fallbackItems4Pool2, rateUpItems4);
				fallbackItems5Pool1 = Utils.setSubtract(fallbackItems5Pool1, rateUpItems5);
				fallbackItems5Pool2 = Utils.setSubtract(fallbackItems5Pool2, rateUpItems5);
			}
		}

		public void removeFromAllPools(int[] itemIds) {
			rateUpItems4 = Utils.setSubtract(rateUpItems4, itemIds);
			rateUpItems5 = Utils.setSubtract(rateUpItems5, itemIds);
			fallbackItems4Pool1 = Utils.setSubtract(fallbackItems4Pool1, itemIds);
			fallbackItems4Pool2 = Utils.setSubtract(fallbackItems4Pool2, itemIds);
			fallbackItems5Pool1 = Utils.setSubtract(fallbackItems5Pool1, itemIds);
			fallbackItems5Pool2 = Utils.setSubtract(fallbackItems5Pool2, itemIds);
		}
	}

	private synchronized int checkPlayerAvatarConstellationLevel(Player player, int itemId) {  // Maybe this would be useful in the Player class?
		ItemData itemData = GameData.getItemDataMap().get(itemId);
		if ((itemData == null) || (itemData.getMaterialType() != MaterialType.MATERIAL_AVATAR)){
			return -2;  // Not an Avatar
		}
		Avatar avatar = player.getAvatars().getAvatarById((itemId % 1000) + 10000000);
		if (avatar == null) {
			return -1;  // Doesn't have
		}
		// Constellation
		int constLevel = avatar.getCoreProudSkillLevel();
		GameItem constItem = player.getInventory().getInventoryTab(ItemType.ITEM_MATERIAL).getItemById(itemId + 100);
		constLevel += (constItem == null)? 0 : constItem.getCount();
		return constLevel;
	}

	private synchronized int[] removeC6FromPool(int[] itemPool, Player player) {
		IntList temp = new IntArrayList();
		for (int itemId : itemPool) {
			if (checkPlayerAvatarConstellationLevel(player, itemId) < 6) {
				temp.add(itemId);
			}
		}
		return temp.toIntArray();
	}

	private synchronized int drawRoulette(int[] weights, int cutoff) {
		// This follows the logic laid out in issue #183
		// Simple weighted selection with an upper bound for the roll that cuts off trailing entries
		// All weights must be >= 0
		int total = 0;
165
166
		for (int weight : weights) {
			if (weight < 0) {
AnimeGitB's avatar
AnimeGitB committed
167
168
				throw new IllegalArgumentException("Weights must be non-negative!");
			}
169
			total += weight;
AnimeGitB's avatar
AnimeGitB committed
170
171
172
		}
		int roll = ThreadLocalRandom.current().nextInt((total < cutoff)? total : cutoff);
		int subTotal = 0;
173
174
		for (int i=0; i<weights.length; i++) {
			subTotal += weights[i];
AnimeGitB's avatar
AnimeGitB committed
175
176
177
178
179
180
181
182
183
184
			if (roll < subTotal) {
				return i;
			}
		}
		// throw new IllegalStateException();
		return 0;  // This should only be reachable if total==0
	}

	private synchronized int doRarePull(int[] featured, int[] fallback1, int[] fallback2, int rarity, GachaBanner banner, PlayerGachaBannerInfo gachaInfo) {
		int itemId = 0;
185
186
187
		boolean pullFeatured = (gachaInfo.getFailedFeaturedItemPulls(rarity) >= 1)  // Lost previous coinflip
							|| (this.randomRange(1, 100) <= banner.getEventChance(rarity));  // Won this coinflip
		if (pullFeatured && (featured.length > 0)) {
AnimeGitB's avatar
AnimeGitB committed
188
189
190
191
192
			itemId = getRandom(featured);
			gachaInfo.setFailedFeaturedItemPulls(rarity, 0);
		} else {
			gachaInfo.addFailedFeaturedItemPulls(rarity, 1);
			if (fallback1.length < 1) {
193
194
195
196
197
198
199
200
				if (fallback2.length < 1) {
					itemId = getRandom((rarity==5)? fallbackItems5Pool2Default : fallbackItems4Pool2Default);
				} else {
					itemId = getRandom(fallback2);
				}
			} else if (fallback2.length < 1) {
				itemId = getRandom(fallback1);
			} else {  // Both pools are possible, use the pool balancer
AnimeGitB's avatar
AnimeGitB committed
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
				int pityPool1 = banner.getPoolBalanceWeight(rarity, gachaInfo.getPityPool(rarity, 1));
				int pityPool2 = banner.getPoolBalanceWeight(rarity, gachaInfo.getPityPool(rarity, 2));
				int chosenPool = switch ((pityPool1 >= pityPool2)? 1 : 0) {  // Larger weight must come first for the hard cutoff to function correctly
					case 1 -> 1 + drawRoulette(new int[] {pityPool1, pityPool2}, 10000);
					default -> 2 - drawRoulette(new int[] {pityPool2, pityPool1}, 10000);
				};
				itemId = switch (chosenPool) {
					case 1:
						gachaInfo.setPityPool(rarity, 1, 0);
						yield getRandom(fallback1);
					default:
						gachaInfo.setPityPool(rarity, 2, 0);
						yield getRandom(fallback2);
				};
			}
		}
		return itemId;
	}

	private synchronized int doPull(GachaBanner banner, PlayerGachaBannerInfo gachaInfo, BannerPools pools) {
		// Pre-increment all pity pools (yes this makes all calculations assume 1-indexed pity)
		gachaInfo.incPityAll();

		int[] weights = {banner.getWeight(5, gachaInfo.getPity5()), banner.getWeight(4, gachaInfo.getPity4()), 10000};
		int levelWon = 5 - drawRoulette(weights, 10000);

		return switch (levelWon) {
			case 5:
				gachaInfo.setPity5(0);
				yield doRarePull(pools.rateUpItems5, pools.fallbackItems5Pool1, pools.fallbackItems5Pool2, 5, banner, gachaInfo);
			case 4:
				gachaInfo.setPity4(0);
				yield doRarePull(pools.rateUpItems4, pools.fallbackItems4Pool1, pools.fallbackItems4Pool2, 4, banner, gachaInfo);
			default:
				yield getRandom(banner.getFallbackItems3());
		};
	}
Melledy's avatar
Melledy committed
238
	
239
	public synchronized void doPulls(Player player, int gachaType, int times) {
Melledy's avatar
Melledy committed
240
241
242
243
		// Sanity check
		if (times != 10 && times != 1) {
			return;
		} 
244
245
		Inventory inventory = player.getInventory();
		if (inventory.getInventoryTab(ItemType.ITEM_WEAPON).getSize() + times > inventory.getInventoryTab(ItemType.ITEM_WEAPON).getMaxCapacity()) {
Melledy's avatar
Melledy committed
246
247
248
249
250
251
252
253
254
255
256
257
			player.sendPacket(new PacketDoGachaRsp());
			return;
		}
		
		// Get banner
		GachaBanner banner = this.getGachaBanners().get(gachaType);
		if (banner == null) {
			player.sendPacket(new PacketDoGachaRsp());
			return;
		}

		// Spend currency
258
259
		ItemParamData cost = banner.getCost(times);
		if (cost.getCount() > 0 && !inventory.payItem(cost)) {
260
			player.sendPacket(new PacketDoGachaRsp());
261
			return;
Melledy's avatar
Melledy committed
262
263
264
		}
		
		// Add to character
AnimeGitB's avatar
AnimeGitB committed
265
266
		PlayerGachaBannerInfo gachaInfo = player.getGachaInfo().getBannerInfo(banner);
		BannerPools pools = new BannerPools(banner);
Melledy's avatar
Melledy committed
267
268
		List<GachaItem> list = new ArrayList<>();
		int stardust = 0, starglitter = 0;
AnimeGitB's avatar
AnimeGitB committed
269
270
271
272
273
274
275
276
277

		if (banner.getRemoveC6FromPool()) {  // The ultimate form of pity (non-vanilla)
			pools.rateUpItems4 = removeC6FromPool(pools.rateUpItems4, player);
			pools.rateUpItems5 = removeC6FromPool(pools.rateUpItems5, player);
			pools.fallbackItems4Pool1 = removeC6FromPool(pools.fallbackItems4Pool1, player);
			pools.fallbackItems4Pool2 = removeC6FromPool(pools.fallbackItems4Pool2, player);
			pools.fallbackItems5Pool1 = removeC6FromPool(pools.fallbackItems5Pool1, player);
			pools.fallbackItems5Pool2 = removeC6FromPool(pools.fallbackItems5Pool2, player);
		}
Melledy's avatar
Melledy committed
278
		
AnimeGitB's avatar
AnimeGitB committed
279
280
281
		for (int i = 0; i < times; i++) {
			// Roll
			int itemId = doPull(banner, gachaInfo, pools);
282
			ItemData itemData = GameData.getItemDataMap().get(itemId);
Melledy's avatar
Melledy committed
283
			if (itemData == null) {
AnimeGitB's avatar
AnimeGitB committed
284
				continue;  // Maybe we should bail out if an item fails instead of rolling the rest?
Melledy's avatar
Melledy committed
285
			}
286
287
288
289

			// Write gacha record
			GachaRecord gachaRecord = new GachaRecord(itemId, player.getUid(), gachaType);
			DatabaseHelper.saveGachaRecord(gachaRecord);
Melledy's avatar
Melledy committed
290
291
292
293
294
295
296
			
			// Create gacha item
			GachaItem.Builder gachaItem = GachaItem.newBuilder();
			int addStardust = 0, addStarglitter = 0;
			boolean isTransferItem = false;
			
			// Const check
AnimeGitB's avatar
AnimeGitB committed
297
298
299
300
301
302
303
			int constellation = checkPlayerAvatarConstellationLevel(player, itemId);
			switch (constellation) {
				case -2:  // Is weapon
					switch (itemData.getRankLevel()) {
						case 5 -> addStarglitter = 10;
						case 4 -> addStarglitter = 2;
						default -> addStardust = 15;
Melledy's avatar
Melledy committed
304
					}
AnimeGitB's avatar
AnimeGitB committed
305
306
307
308
309
310
311
312
313
314
315
316
317
					break;
				case -1:  // New character
					gachaItem.setIsGachaItemNew(true);
					break;
				default:
					if (constellation >= 6) {  // C6, give consolation starglitter
						addStarglitter = (itemData.getRankLevel()==5)? 25 : 5;
					} else {  // C0-C5, give constellation item
						if (banner.getRemoveC6FromPool() && constellation == 5) {  // New C6, remove it from the pools so we don't get C7 in a 10pull
							pools.removeFromAllPools(new int[] {itemId});
						}
						addStarglitter = (itemData.getRankLevel()==5)? 10 : 2;
						int constItemId = itemId + 100;
318
						GameItem constItem = inventory.getInventoryTab(ItemType.ITEM_MATERIAL).getItemById(constItemId);
Melledy's avatar
Melledy committed
319
						gachaItem.addTransferItems(GachaTransferItem.newBuilder().setItem(ItemParam.newBuilder().setItemId(constItemId).setCount(1)).setIsTransferItemNew(constItem == null));
320
						inventory.addItem(constItemId, 1);
Melledy's avatar
Melledy committed
321
322
					}
					isTransferItem = true;
AnimeGitB's avatar
AnimeGitB committed
323
					break;
Melledy's avatar
Melledy committed
324
325
326
			}

			// Create item
327
			GameItem item = new GameItem(itemData);
Melledy's avatar
Melledy committed
328
			gachaItem.setGachaItem(item.toItemParam());
329
			inventory.addItem(item);
Melledy's avatar
Melledy committed
330
331
332
333
334
335
			
			stardust += addStardust;
			starglitter += addStarglitter;
			
			if (addStardust > 0) {
				gachaItem.addTokenItemList(ItemParam.newBuilder().setItemId(stardustId).setCount(addStardust));
AnimeGitB's avatar
AnimeGitB committed
336
337
			}
			if (addStarglitter > 0) {
Melledy's avatar
Melledy committed
338
339
340
341
342
343
344
345
346
347
348
349
				ItemParam starglitterParam = ItemParam.newBuilder().setItemId(starglitterId).setCount(addStarglitter).build();
				if (isTransferItem) {
					gachaItem.addTransferItems(GachaTransferItem.newBuilder().setItem(starglitterParam));
				}
				gachaItem.addTokenItemList(starglitterParam);
			}
			
			list.add(gachaItem.build());
		}
		
		// Add stardust/starglitter
		if (stardust > 0) {
350
			inventory.addItem(stardustId, stardust);
AnimeGitB's avatar
AnimeGitB committed
351
352
		}
		if (starglitter > 0) {
353
			inventory.addItem(starglitterId, starglitter);
Melledy's avatar
Melledy committed
354
355
356
357
358
		}
		
		// Packets
		player.sendPacket(new PacketDoGachaRsp(banner, list));
	}
359
360
361
362
363

	private synchronized void startWatcher(GameServer server) {
		if(this.watchService == null) {
			try {
				this.watchService = FileSystems.getDefault().newWatchService();
364
				Path path = new File(DATA()).toPath();
365
				path.register(watchService, new WatchEvent.Kind[]{StandardWatchEventKinds.ENTRY_MODIFY}, SensitivityWatchEventModifier.HIGH);
366
367
368
369
370
371
372
373
374
375
376
			} catch (Exception e) {
				Grasscutter.getLogger().error("Unable to load the Gacha Manager Watch Service. If ServerOptions.watchGacha is true it will not auto-reload");
				e.printStackTrace();
			}
		} else {
			Grasscutter.getLogger().error("Cannot reinitialise watcher ");
		}
	}

	@Subscribe
	public synchronized void watchBannerJson(GameServerTickEvent tickEvent) {
377
		if(GAME_OPTIONS.watchGachaConfig) {
Benjamin Elsdon's avatar
Benjamin Elsdon committed
378
			try {
379
380
				WatchKey watchKey = watchService.take();

Benjamin Elsdon's avatar
Benjamin Elsdon committed
381
382
383
384
385
386
				for (WatchEvent<?> event : watchKey.pollEvents()) {
					final Path changed = (Path) event.context();
					if (changed.endsWith("Banners.json")) {
						Grasscutter.getLogger().info("Change detected with banners.json. Reloading gacha config");
						this.load();
					}
387
				}
388
389
390
391
392
393

				boolean valid = watchKey.reset();
				if (!valid) {
					Grasscutter.getLogger().error("Unable to reset Gacha Manager Watch Key. Auto-reload of banners.json will no longer work.");
					return;
				}
Benjamin Elsdon's avatar
Benjamin Elsdon committed
394
395
			} catch (Exception e) {
				e.printStackTrace();
396
397
398
			}
		}
	}
Melledy's avatar
Melledy committed
399
	
400
	@Deprecated
Melledy's avatar
Melledy committed
401
402
403
404
405
406
407
408
409
	private synchronized GetGachaInfoRsp createProto() {
		GetGachaInfoRsp.Builder proto = GetGachaInfoRsp.newBuilder().setGachaRandom(12345);
		
		for (GachaBanner banner : getGachaBanners().values()) {
			proto.addGachaInfoList(banner.toProto());
		}
				
		return proto.build();
	}
410
411
412
413
414
415
416
417
418
419

	private synchronized GetGachaInfoRsp createProto(String sessionKey) {
		GetGachaInfoRsp.Builder proto = GetGachaInfoRsp.newBuilder().setGachaRandom(12345);
		
		for (GachaBanner banner : getGachaBanners().values()) {
			proto.addGachaInfoList(banner.toProto(sessionKey));
		}
				
		return proto.build();
	}
Melledy's avatar
Melledy committed
420
	
421
	@Deprecated
Melledy's avatar
Melledy committed
422
423
424
425
426
427
	public GetGachaInfoRsp toProto() {
		if (this.cachedProto == null) {
			this.cachedProto = createProto();
		}
		return this.cachedProto;
	}
428
429
430
431

	public GetGachaInfoRsp toProto(String sessionKey) {
		return createProto(sessionKey);
	}
Melledy's avatar
Melledy committed
432
}