ChestService.php 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533
  1. <?php
  2. namespace App\Module\GameItems\Services;
  3. use App\Module\GameItems\Enums\ITEM_TYPE;
  4. use App\Module\GameItems\Logics\ChestContent as ChestContentLogic;
  5. use App\Module\GameItems\Logics\ChestOpenCostLogic;
  6. use App\Module\GameItems\Logics\Item as ItemLogic;
  7. use App\Module\GameItems\Logics\PityTime as PityTimeLogic;
  8. use App\Module\GameItems\Models\ItemChestContent;
  9. use App\Module\GameItems\Models\ItemChestOpenLog;
  10. use App\Module\GameItems\Models\Item;
  11. use App\Module\GameItems\Models\ItemPityTime;
  12. use Exception;
  13. use Illuminate\Support\Collection;
  14. use Illuminate\Support\Facades\DB;
  15. /**
  16. * 宝箱服务类
  17. *
  18. * 提供宝箱相关的服务,包括开启宝箱、获取宝箱内容预览等功能。
  19. * 该类处理宝箱开启的复杂逻辑,包括随机抽取物品、保底机制、物品组处理等,
  20. * 通过调用ChestContentLogic、ItemLogic和PityTimeLogic类实现具体的业务逻辑处理。
  21. */
  22. class ChestService
  23. {
  24. /**
  25. * @var ItemService
  26. */
  27. protected $itemService;
  28. /**
  29. * @var ChestContentLogic
  30. */
  31. protected $chestContentLogic;
  32. /**
  33. * @var ItemLogic
  34. */
  35. protected $itemLogic;
  36. /**
  37. * @var PityTimeLogic
  38. */
  39. protected $pityTimeLogic;
  40. /**
  41. * @var ChestOpenCostLogic
  42. */
  43. protected $chestOpenCostLogic;
  44. /**
  45. * 构造函数
  46. *
  47. * @param ItemService $itemService
  48. */
  49. public function __construct(ItemService $itemService)
  50. {
  51. $this->itemService = $itemService;
  52. $this->chestContentLogic = new ChestContentLogic();
  53. $this->itemLogic = new ItemLogic();
  54. $this->pityTimeLogic = new PityTimeLogic();
  55. $this->chestOpenCostLogic = new ChestOpenCostLogic(
  56. app(\App\Module\GameItems\Repositorys\ItemChestOpenCostRepository::class),
  57. $this->itemLogic
  58. );
  59. }
  60. /**
  61. * 开启宝箱
  62. *
  63. * @param int $userId 用户ID
  64. * @param int $chestId 宝箱ID
  65. * @param int $quantity 开启数量
  66. * @param array $options 选项
  67. * @return array 开启结果
  68. * @throws Exception
  69. */
  70. public function openChest(int $userId, int $chestId, int $quantity = 1, array $options = []): array
  71. {
  72. // 获取宝箱信息
  73. $chest = Item::findOrFail($chestId);
  74. // 检查是否为宝箱类型
  75. if ($chest->type != ITEM_TYPE::OPENABLE) {
  76. throw new Exception("物品 {$chestId} 不是宝箱类型");
  77. }
  78. // 获取宝箱内容配置
  79. $chestContents = ItemChestContent::where('chest_id', $chestId)->get();
  80. if ($chestContents->isEmpty()) {
  81. throw new Exception("宝箱 {$chestId} 没有配置内容");
  82. }
  83. // 验证宝箱开启消耗
  84. list($isValid, $message, $costDetails) = $this->chestOpenCostLogic->validateChestOpenCosts($userId, $chestId, $quantity);
  85. if (!$isValid) {
  86. throw new Exception($message);
  87. }
  88. // 开始事务
  89. DB::beginTransaction();
  90. try {
  91. // 扣除宝箱开启消耗
  92. if (!empty($costDetails)) {
  93. $deductResult = $this->chestOpenCostLogic->deductChestOpenCosts(
  94. $userId,
  95. $costDetails,
  96. "开启宝箱 ID:{$chestId}"
  97. );
  98. if (!$deductResult) {
  99. throw new Exception("扣除宝箱开启消耗失败");
  100. }
  101. }
  102. // 消耗宝箱
  103. $consumeResult = $this->itemService->consumeItem(
  104. $userId,
  105. $chestId,
  106. null,
  107. $quantity,
  108. [
  109. 'source_type' => 'chest_open',
  110. 'source_id' => $chestId,
  111. 'details' => ['quantity' => $quantity],
  112. 'ip_address' => $options['ip_address'] ?? null,
  113. 'device_info' => $options['device_info'] ?? null,
  114. ]
  115. );
  116. // 获取宝箱掉落物品数量范围
  117. $minDropCount = $chest->numeric_attributes['min_drop_count'] ?? 1;
  118. $maxDropCount = $chest->numeric_attributes['max_drop_count'] ?? 1;
  119. $allResults = [];
  120. $allPityTriggered = false;
  121. $pityContentId = null;
  122. // 循环开启指定数量的宝箱
  123. for ($i = 0; $i < $quantity; $i++) {
  124. // 随机决定本次开启掉落的物品数量
  125. $dropCount = mt_rand($minDropCount, $maxDropCount);
  126. // 获取开启结果
  127. $openResult = $this->getChestOpenResult($userId, $chestId, $chestContents, $dropCount);
  128. // 添加物品到用户背包
  129. foreach ($openResult['items'] as $item) {
  130. $this->itemService->addItem(
  131. $userId,
  132. $item['item_id'],
  133. $item['quantity'],
  134. [
  135. 'source_type' => 'chest_open',
  136. 'source_id' => $chestId,
  137. 'details' => [
  138. 'chest_id' => $chestId,
  139. 'is_pity' => $item['is_pity'] ?? false,
  140. ],
  141. 'ip_address' => $options['ip_address'] ?? null,
  142. 'device_info' => $options['device_info'] ?? null,
  143. ]
  144. );
  145. }
  146. // 记录本次开启结果
  147. $allResults[] = $openResult['items'];
  148. // 更新保底触发状态
  149. if ($openResult['pity_triggered']) {
  150. $allPityTriggered = true;
  151. $pityContentId = $openResult['pity_content_id'];
  152. }
  153. }
  154. // 记录宝箱开启日志
  155. $openLog = ItemChestOpenLog::create([
  156. 'user_id' => $userId,
  157. 'chest_id' => $chestId,
  158. 'quantity' => $quantity,
  159. 'results' => $allResults,
  160. 'pity_triggered' => $allPityTriggered,
  161. 'pity_content_id' => $pityContentId,
  162. 'open_time' => now(),
  163. 'ip_address' => $options['ip_address'] ?? null,
  164. 'device_info' => $options['device_info'] ?? null,
  165. ]);
  166. DB::commit();
  167. return [
  168. 'success' => true,
  169. 'chest_id' => $chestId,
  170. 'quantity' => $quantity,
  171. 'results' => $allResults,
  172. 'pity_triggered' => $allPityTriggered,
  173. 'log_id' => $openLog->id,
  174. 'costs' => $costDetails,
  175. ];
  176. } catch (Exception $e) {
  177. DB::rollBack();
  178. throw $e;
  179. }
  180. }
  181. /**
  182. * 获取宝箱开启结果
  183. *
  184. * @param int $userId 用户ID
  185. * @param int $chestId 宝箱ID
  186. * @param Collection $chestContents 宝箱内容配置
  187. * @param int $dropCount 掉落物品数量
  188. * @return array 开启结果
  189. */
  190. protected function getChestOpenResult(int $userId, int $chestId, Collection $chestContents, int $dropCount): array
  191. {
  192. $items = [];
  193. $selectedContentIds = [];
  194. $pityTriggered = false;
  195. $pityContentId = null;
  196. // 获取用户保底计数
  197. $pityTimes = ItemPityTime::where('user_id', $userId)
  198. ->where('chest_id', $chestId)
  199. ->get()
  200. ->keyBy('chest_content_id');
  201. // 计算调整后的权重
  202. $contentsWithAdjustedWeight = $chestContents->map(function ($content) use ($pityTimes) {
  203. $pityCount = 0;
  204. if (isset($pityTimes[$content->id])) {
  205. $pityCount = $pityTimes[$content->id]->current_count;
  206. }
  207. $adjustedWeight = $this->chestContentLogic->getAdjustedWeight($content, $pityCount);
  208. return [
  209. 'content' => $content,
  210. 'adjusted_weight' => $adjustedWeight,
  211. 'pity_count' => $pityCount,
  212. ];
  213. });
  214. // 检查是否有达到保底次数的内容
  215. $guaranteedContents = $contentsWithAdjustedWeight->filter(function ($item) {
  216. return $item['content']->pity_count > 0 && $item['pity_count'] >= $item['content']->pity_count;
  217. });
  218. // 如果有达到保底次数的内容,优先选择
  219. if ($guaranteedContents->isNotEmpty()) {
  220. $guaranteedContent = $guaranteedContents->first();
  221. $content = $guaranteedContent['content'];
  222. // 添加到结果
  223. $items[] = $this->processChestContent($content, true);
  224. $selectedContentIds[] = $content->id;
  225. // 重置保底计数
  226. $this->resetPityCount($userId, $chestId, $content->id);
  227. // 更新保底触发状态
  228. $pityTriggered = true;
  229. $pityContentId = $content->id;
  230. // 减少掉落数量
  231. $dropCount--;
  232. }
  233. // 如果还需要掉落物品,继续随机选择
  234. if ($dropCount > 0) {
  235. // 计算总权重
  236. $totalWeight = $contentsWithAdjustedWeight->sum('adjusted_weight');
  237. // 随机选择指定数量的物品
  238. for ($i = 0; $i < $dropCount; $i++) {
  239. // 过滤掉已选择的内容(如果不允许重复)
  240. $availableContents = $contentsWithAdjustedWeight->filter(function ($item) use ($selectedContentIds) {
  241. return !in_array($item['content']->id, $selectedContentIds) || $item['content']->allow_duplicate;
  242. });
  243. if ($availableContents->isEmpty()) {
  244. break;
  245. }
  246. // 重新计算总权重
  247. $availableTotalWeight = $availableContents->sum('adjusted_weight');
  248. // 随机选择
  249. $randomValue = mt_rand(1, (int)($availableTotalWeight * 1000)) / 1000;
  250. $currentWeight = 0;
  251. $selectedContent = null;
  252. foreach ($availableContents as $item) {
  253. $currentWeight += $item['adjusted_weight'];
  254. if ($randomValue <= $currentWeight) {
  255. $selectedContent = $item['content'];
  256. break;
  257. }
  258. }
  259. // 如果没有选中(可能由于浮点数精度问题),选择第一个
  260. if (!$selectedContent && $availableContents->isNotEmpty()) {
  261. $selectedContent = $availableContents->first()['content'];
  262. }
  263. if ($selectedContent) {
  264. // 添加到结果
  265. $items[] = $this->processChestContent($selectedContent, false);
  266. $selectedContentIds[] = $selectedContent->id;
  267. // 增加保底计数
  268. $this->incrementPityCount($userId, $chestId, $selectedContent->id);
  269. }
  270. }
  271. }
  272. // 增加其他内容的保底计数
  273. foreach ($chestContents as $content) {
  274. if (!in_array($content->id, $selectedContentIds) && $content->pity_count > 0) {
  275. $this->incrementPityCount($userId, $chestId, $content->id);
  276. }
  277. }
  278. return [
  279. 'items' => $items,
  280. 'pity_triggered' => $pityTriggered,
  281. 'pity_content_id' => $pityContentId,
  282. ];
  283. }
  284. /**
  285. * 处理宝箱内容
  286. *
  287. * @param ItemChestContent $content 宝箱内容
  288. * @param bool $isPity 是否为保底触发
  289. * @return array 处理结果
  290. */
  291. protected function processChestContent(ItemChestContent $content, bool $isPity): array
  292. {
  293. // 获取随机数量
  294. $quantity = $this->chestContentLogic->getRandomQuantity($content);
  295. // 如果是物品组,随机选择一个物品
  296. if ($this->chestContentLogic->isGroupContent($content)) {
  297. $group = $content->group;
  298. $groupLogic = new \App\Module\GameItems\Logics\Group();
  299. $item = $groupLogic->getRandomItem($group);
  300. if (!$item) {
  301. throw new Exception("物品组 {$group->id} 没有配置物品");
  302. }
  303. return [
  304. 'item_id' => $item->id,
  305. 'quantity' => $quantity,
  306. 'is_pity' => $isPity,
  307. 'content_id' => $content->id,
  308. ];
  309. }
  310. // 直接返回物品
  311. return [
  312. 'item_id' => $content->item_id,
  313. 'quantity' => $quantity,
  314. 'is_pity' => $isPity,
  315. 'content_id' => $content->id,
  316. ];
  317. }
  318. /**
  319. * 增加保底计数
  320. *
  321. * @param int $userId 用户ID
  322. * @param int $chestId 宝箱ID
  323. * @param int $contentId 内容ID
  324. * @return void
  325. */
  326. protected function incrementPityCount(int $userId, int $chestId, int $contentId): void
  327. {
  328. $pityTime = ItemPityTime::firstOrCreate(
  329. [
  330. 'user_id' => $userId,
  331. 'chest_id' => $chestId,
  332. 'chest_content_id' => $contentId,
  333. ],
  334. [
  335. 'current_count' => 0,
  336. ]
  337. );
  338. $this->pityTimeLogic->incrementCount($pityTime);
  339. }
  340. /**
  341. * 重置保底计数
  342. *
  343. * @param int $userId 用户ID
  344. * @param int $chestId 宝箱ID
  345. * @param int $contentId 内容ID
  346. * @return void
  347. */
  348. protected function resetPityCount(int $userId, int $chestId, int $contentId): void
  349. {
  350. $pityTime = ItemPityTime::firstOrCreate(
  351. [
  352. 'user_id' => $userId,
  353. 'chest_id' => $chestId,
  354. 'chest_content_id' => $contentId,
  355. ],
  356. [
  357. 'current_count' => 0,
  358. ]
  359. );
  360. $this->pityTimeLogic->resetCount($pityTime);
  361. }
  362. /**
  363. * 获取宝箱内容预览
  364. *
  365. * @param int $chestId 宝箱ID
  366. * @return array 宝箱内容预览
  367. */
  368. public function getChestContentPreview(int $chestId): array
  369. {
  370. // 获取宝箱信息
  371. $chest = Item::findOrFail($chestId);
  372. // 检查是否为宝箱类型
  373. if ($chest->type != ITEM_TYPE::OPENABLE) {
  374. throw new Exception("物品 {$chestId} 不是宝箱类型");
  375. }
  376. // 获取宝箱内容配置
  377. $chestContents = ItemChestContent::where('chest_id', $chestId)
  378. ->with(['item', 'group.groupItems.item'])
  379. ->get();
  380. if ($chestContents->isEmpty()) {
  381. return [
  382. 'chest_id' => $chestId,
  383. 'chest_name' => $chest->name,
  384. 'contents' => [],
  385. ];
  386. }
  387. // 计算总权重
  388. $totalWeight = $chestContents->sum('weight');
  389. // 处理宝箱内容
  390. $contents = [];
  391. foreach ($chestContents as $content) {
  392. // 计算概率
  393. $probability = ($totalWeight > 0) ? ($content->weight / $totalWeight * 100) : 0;
  394. if ($content->isGroupContent()) {
  395. // 处理物品组
  396. $group = $content->group;
  397. $groupItems = [];
  398. // 计算物品组内总权重
  399. $groupTotalWeight = $group->groupItems->sum('weight');
  400. foreach ($group->groupItems as $groupItem) {
  401. // 计算物品在组内的概率
  402. $groupItemProbability = ($groupTotalWeight > 0) ? ($groupItem->weight / $groupTotalWeight * 100) : 0;
  403. // 计算物品的综合概率
  404. $combinedProbability = $probability * $groupItemProbability / 100;
  405. $groupItems[] = [
  406. 'item_id' => $groupItem->item_id,
  407. 'item_name' => $groupItem->item->name,
  408. 'item_icon' => $groupItem->item->icon,
  409. 'group_probability' => $groupItemProbability,
  410. 'combined_probability' => $combinedProbability,
  411. ];
  412. }
  413. $contents[] = [
  414. 'content_id' => $content->id,
  415. 'type' => 'group',
  416. 'group_id' => $group->id,
  417. 'group_name' => $group->name,
  418. 'min_quantity' => $content->min_quantity,
  419. 'max_quantity' => $content->max_quantity,
  420. 'probability' => $probability,
  421. 'pity_count' => $content->pity_count,
  422. 'items' => $groupItems,
  423. ];
  424. } else {
  425. // 处理单个物品
  426. $item = $content->item;
  427. $contents[] = [
  428. 'content_id' => $content->id,
  429. 'type' => 'item',
  430. 'item_id' => $item->id,
  431. 'item_name' => $item->name,
  432. 'item_icon' => $item->icon,
  433. 'min_quantity' => $content->min_quantity,
  434. 'max_quantity' => $content->max_quantity,
  435. 'probability' => $probability,
  436. 'pity_count' => $content->pity_count,
  437. ];
  438. }
  439. }
  440. // 获取宝箱开启消耗配置
  441. $costs = $this->chestOpenCostLogic->getActiveChestCosts($chestId);
  442. return [
  443. 'chest_id' => $chestId,
  444. 'chest_name' => $chest->name,
  445. 'min_drop_count' => $chest->numeric_attributes['min_drop_count'] ?? 1,
  446. 'max_drop_count' => $chest->numeric_attributes['max_drop_count'] ?? 1,
  447. 'contents' => $contents,
  448. 'costs' => $costs,
  449. ];
  450. }
  451. /**
  452. * 获取宝箱开启消耗配置
  453. *
  454. * @param int $chestId 宝箱ID
  455. * @return array 宝箱开启消耗配置
  456. */
  457. public function getChestOpenCosts(int $chestId): array
  458. {
  459. return $this->chestOpenCostLogic->getActiveChestCosts($chestId);
  460. }
  461. }