diff --git a/app-modules/economy/database/factories/ShopListingFactory.php b/app-modules/economy/database/factories/ShopListingFactory.php new file mode 100644 index 00000000..605d0b3a --- /dev/null +++ b/app-modules/economy/database/factories/ShopListingFactory.php @@ -0,0 +1,41 @@ + + */ +final class ShopListingFactory extends Factory +{ + protected $model = ShopListing::class; + + public function definition(): array + { + return [ + 'tenant_id' => Tenant::factory(), + 'item_id' => Item::factory(), + 'price' => fake()->numberBetween(10, 500), + 'stock' => null, + 'available_from' => null, + 'available_until' => null, + 'active' => true, + ]; + } + + public function limited(int $stock = 10): static + { + return $this->state(['stock' => $stock]); + } + + public function inactive(): static + { + return $this->state(['active' => false]); + } +} diff --git a/app-modules/economy/database/factories/TradeFactory.php b/app-modules/economy/database/factories/TradeFactory.php new file mode 100644 index 00000000..f3c4e2ca --- /dev/null +++ b/app-modules/economy/database/factories/TradeFactory.php @@ -0,0 +1,54 @@ + + */ +final class TradeFactory extends Factory +{ + protected $model = Trade::class; + + public function definition(): array + { + return [ + 'tenant_id' => Tenant::factory(), + 'initiator_character_id' => Character::factory(), + 'receiver_character_id' => Character::factory(), + 'status' => TradeStatus::Pending, + 'resolved_at' => null, + ]; + } + + public function accepted(): static + { + return $this->state([ + 'status' => TradeStatus::Accepted, + 'resolved_at' => now(), + ]); + } + + public function rejected(): static + { + return $this->state([ + 'status' => TradeStatus::Rejected, + 'resolved_at' => now(), + ]); + } + + public function cancelled(): static + { + return $this->state([ + 'status' => TradeStatus::Cancelled, + 'resolved_at' => now(), + ]); + } +} diff --git a/app-modules/economy/database/factories/TradeItemFactory.php b/app-modules/economy/database/factories/TradeItemFactory.php new file mode 100644 index 00000000..3e4d8a2a --- /dev/null +++ b/app-modules/economy/database/factories/TradeItemFactory.php @@ -0,0 +1,33 @@ + + */ +final class TradeItemFactory extends Factory +{ + protected $model = TradeItem::class; + + public function definition(): array + { + return [ + 'trade_id' => Trade::factory(), + 'character_item_id' => CharacterItem::factory(), + 'direction' => TradeDirection::Offer, + ]; + } + + public function asRequest(): static + { + return $this->state(['direction' => TradeDirection::Request]); + } +} diff --git a/app-modules/economy/database/migrations/2026_03_25_100001_create_shop_listings_table.php b/app-modules/economy/database/migrations/2026_03_25_100001_create_shop_listings_table.php new file mode 100644 index 00000000..2a52031b --- /dev/null +++ b/app-modules/economy/database/migrations/2026_03_25_100001_create_shop_listings_table.php @@ -0,0 +1,33 @@ +uuid('id')->primary(); + $table->foreignId('tenant_id')->constrained('tenants')->nullOnDelete(); + $table->foreignUuid('item_id')->constrained('items'); + $table->integer('price'); + $table->integer('stock')->nullable(); + $table->timestamp('available_from')->nullable(); + $table->timestamp('available_until')->nullable(); + $table->boolean('active')->default(true); + $table->timestamps(); + + $table->index(['tenant_id', 'active']); + $table->index('item_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('shop_listings'); + } +}; diff --git a/app-modules/economy/database/migrations/2026_03_25_100002_create_trades_table.php b/app-modules/economy/database/migrations/2026_03_25_100002_create_trades_table.php new file mode 100644 index 00000000..42444c0d --- /dev/null +++ b/app-modules/economy/database/migrations/2026_03_25_100002_create_trades_table.php @@ -0,0 +1,31 @@ +uuid('id')->primary(); + $table->foreignId('tenant_id')->constrained('tenants')->nullOnDelete(); + $table->foreignUuid('initiator_character_id')->constrained('characters')->cascadeOnDelete(); + $table->foreignUuid('receiver_character_id')->constrained('characters')->cascadeOnDelete(); + $table->string('status', 50)->default('pending'); + $table->timestamp('resolved_at')->nullable(); + $table->timestamps(); + + $table->index(['initiator_character_id', 'status']); + $table->index(['receiver_character_id', 'status']); + }); + } + + public function down(): void + { + Schema::dropIfExists('trades'); + } +}; diff --git a/app-modules/economy/database/migrations/2026_03_25_100003_create_trade_items_table.php b/app-modules/economy/database/migrations/2026_03_25_100003_create_trade_items_table.php new file mode 100644 index 00000000..fbef34b8 --- /dev/null +++ b/app-modules/economy/database/migrations/2026_03_25_100003_create_trade_items_table.php @@ -0,0 +1,26 @@ +uuid('id')->primary(); + $table->foreignUuid('trade_id')->constrained('trades')->cascadeOnDelete(); + $table->foreignUuid('character_item_id')->constrained('character_items'); + $table->string('direction', 20); + $table->timestamp('created_at')->nullable(); + }); + } + + public function down(): void + { + Schema::dropIfExists('trade_items'); + } +}; diff --git a/app-modules/economy/src/Providers/EconomyServiceProvider.php b/app-modules/economy/src/Providers/EconomyServiceProvider.php index b7584116..0fcb7238 100644 --- a/app-modules/economy/src/Providers/EconomyServiceProvider.php +++ b/app-modules/economy/src/Providers/EconomyServiceProvider.php @@ -4,6 +4,10 @@ namespace He4rt\Economy\Providers; +use He4rt\Economy\Shop\Models\ShopListing; +use He4rt\Economy\Trade\Models\Trade; +use He4rt\Economy\Trade\Models\TradeItem; +use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Support\ServiceProvider; class EconomyServiceProvider extends ServiceProvider @@ -13,5 +17,11 @@ public function register(): void {} public function boot(): void { $this->loadMigrationsFrom(__DIR__.'/../../database/migrations'); + + Relation::morphMap([ + 'shop_listing' => ShopListing::class, + 'trade' => Trade::class, + 'trade_item' => TradeItem::class, + ]); } } diff --git a/app-modules/economy/src/Shop/Actions/PurchaseItem.php b/app-modules/economy/src/Shop/Actions/PurchaseItem.php new file mode 100644 index 00000000..a1c14459 --- /dev/null +++ b/app-modules/economy/src/Shop/Actions/PurchaseItem.php @@ -0,0 +1,88 @@ +findOrFail($dto->characterId); + $listing = ShopListing::query()->with('item')->findOrFail($dto->shopListingId); + + if (!$listing->isAvailable()) { + if ($listing->stock !== null && $listing->stock <= 0) { + throw ItemOutOfStockException::forListing($listing->id); + } + + throw ItemNotAvailableException::forListing($listing->id); + } + + $item = $listing->item; + + if ($item->level_required > 0 && $character->level < $item->level_required) { + throw LevelRequirementNotMetException::forCharacter($character->level, $item->level_required); + } + + $alreadyOwns = CharacterItem::query() + ->where('character_id', $character->id) + ->where('item_id', $item->id) + ->exists(); + + if ($alreadyOwns) { + throw ItemAlreadyOwnedException::forCharacter($character->id, $item->id); + } + + $wallet = $character->getOrCreateWallet(); + + return DB::transaction(function () use ($wallet, $listing, $character, $item): CharacterItem { + $this->debit->handle(new DebitDTO( + walletId: $wallet->id, + amount: $listing->price, + referenceType: 'shop_listing', + referenceId: $listing->id, + description: sprintf('Purchase: %s', $item->name), + )); + + if ($listing->stock !== null) { + $listing->decrement('stock'); + } + + return $this->addItemToInventory->handle(new AddItemToInventoryDTO( + characterId: $character->id, + itemId: $item->id, + tenantId: $listing->tenant_id, + acquiredVia: AcquisitionMethod::Purchase, + )); + }); + } +} diff --git a/app-modules/economy/src/Shop/DTOs/PurchaseItemDTO.php b/app-modules/economy/src/Shop/DTOs/PurchaseItemDTO.php new file mode 100644 index 00000000..e8ca5a74 --- /dev/null +++ b/app-modules/economy/src/Shop/DTOs/PurchaseItemDTO.php @@ -0,0 +1,13 @@ + */ + use HasFactory; + use HasUuids; + + protected $fillable = [ + 'tenant_id', + 'item_id', + 'price', + 'stock', + 'available_from', + 'available_until', + 'active', + ]; + + /** + * @return BelongsTo + */ + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } + + /** + * @return BelongsTo + */ + public function item(): BelongsTo + { + return $this->belongsTo(Item::class); + } + + public function isAvailable(): bool + { + if (!$this->active) { + return false; + } + + if ($this->available_from && $this->available_from->isFuture()) { + return false; + } + + if ($this->available_until && $this->available_until->isPast()) { + return false; + } + + if ($this->stock !== null && $this->stock <= 0) { + return false; + } + + return true; + } + + protected static function newFactory(): ShopListingFactory + { + return ShopListingFactory::new(); + } + + protected function casts(): array + { + return [ + 'price' => 'integer', + 'stock' => 'integer', + 'active' => 'boolean', + 'available_from' => 'datetime', + 'available_until' => 'datetime', + ]; + } +} diff --git a/app-modules/economy/src/Trade/Actions/AcceptTrade.php b/app-modules/economy/src/Trade/Actions/AcceptTrade.php new file mode 100644 index 00000000..4afc2ed8 --- /dev/null +++ b/app-modules/economy/src/Trade/Actions/AcceptTrade.php @@ -0,0 +1,77 @@ +with('items')->findOrFail($tradeId); + + if (!$trade->isPending()) { + throw InvalidTradeException::notPending($tradeId); + } + + if ($trade->receiver_character_id !== $receiverCharacterId) { + throw InvalidTradeException::notAuthorized(); + } + + return DB::transaction(function () use ($trade): Trade { + foreach ($trade->items as $tradeItem) { + $characterItem = CharacterItem::query()->findOrFail($tradeItem->character_item_id); + + $expectedOwner = $tradeItem->direction === TradeDirection::Offer + ? $trade->initiator_character_id + : $trade->receiver_character_id; + + if ($characterItem->character_id !== $expectedOwner) { + throw TradeItemNotValidException::noLongerValid($tradeItem->character_item_id); + } + + $isEquipped = CharacterEquipment::query() + ->where('character_item_id', $tradeItem->character_item_id) + ->exists(); + + if ($isEquipped) { + throw TradeItemNotValidException::currentlyEquipped($tradeItem->character_item_id); + } + } + + foreach ($trade->items as $tradeItem) { + $newOwner = $tradeItem->direction === TradeDirection::Offer + ? $trade->receiver_character_id + : $trade->initiator_character_id; + + CharacterItem::query() + ->where('id', $tradeItem->character_item_id) + ->update([ + 'character_id' => $newOwner, + 'acquired_via' => 'trade', + 'acquired_at' => now(), + ]); + } + + $trade->update([ + 'status' => TradeStatus::Accepted, + 'resolved_at' => now(), + ]); + + return $trade->fresh('items'); + }); + } +} diff --git a/app-modules/economy/src/Trade/Actions/CancelTrade.php b/app-modules/economy/src/Trade/Actions/CancelTrade.php new file mode 100644 index 00000000..7d32b652 --- /dev/null +++ b/app-modules/economy/src/Trade/Actions/CancelTrade.php @@ -0,0 +1,35 @@ +findOrFail($tradeId); + + if (!$trade->isPending()) { + throw InvalidTradeException::notPending($tradeId); + } + + if ($trade->initiator_character_id !== $initiatorCharacterId) { + throw InvalidTradeException::notAuthorized(); + } + + $trade->update([ + 'status' => TradeStatus::Cancelled, + 'resolved_at' => now(), + ]); + + return $trade->fresh(); + } +} diff --git a/app-modules/economy/src/Trade/Actions/CreateTrade.php b/app-modules/economy/src/Trade/Actions/CreateTrade.php new file mode 100644 index 00000000..a95faac5 --- /dev/null +++ b/app-modules/economy/src/Trade/Actions/CreateTrade.php @@ -0,0 +1,97 @@ +initiatorCharacterId === $dto->receiverCharacterId) { + throw InvalidTradeException::selfTrade(); + } + + $this->validateItems($dto->offeredItemIds, $dto->initiatorCharacterId); + $this->validateItems($dto->requestedItemIds, $dto->receiverCharacterId); + + return DB::transaction(function () use ($dto): Trade { + $trade = Trade::query()->create([ + 'tenant_id' => $dto->tenantId, + 'initiator_character_id' => $dto->initiatorCharacterId, + 'receiver_character_id' => $dto->receiverCharacterId, + 'status' => TradeStatus::Pending, + ]); + + foreach ($dto->offeredItemIds as $characterItemId) { + $trade->items()->create([ + 'character_item_id' => $characterItemId, + 'direction' => TradeDirection::Offer, + ]); + } + + foreach ($dto->requestedItemIds as $characterItemId) { + $trade->items()->create([ + 'character_item_id' => $characterItemId, + 'direction' => TradeDirection::Request, + ]); + } + + return $trade->load('items'); + }); + } + + /** + * @param array $characterItemIds + * + * @throws TradeItemNotValidException + */ + private function validateItems(array $characterItemIds, string $expectedCharacterId): void + { + foreach ($characterItemIds as $characterItemId) { + $characterItem = CharacterItem::query() + ->with('item') + ->findOrFail($characterItemId); + + if ($characterItem->character_id !== $expectedCharacterId) { + throw TradeItemNotValidException::notOwned($characterItemId); + } + + if (!$characterItem->item->is_tradeable) { + throw TradeItemNotValidException::notTradeable($characterItemId); + } + + $isEquipped = CharacterEquipment::query() + ->where('character_item_id', $characterItemId) + ->exists(); + + if ($isEquipped) { + throw TradeItemNotValidException::currentlyEquipped($characterItemId); + } + + $inPendingTrade = Trade::query() + ->where('status', TradeStatus::Pending) + ->whereHas('items', fn (Builder $q) => $q->where('character_item_id', $characterItemId)) + ->exists(); + + if ($inPendingTrade) { + throw TradeItemNotValidException::inPendingTrade($characterItemId); + } + } + } +} diff --git a/app-modules/economy/src/Trade/Actions/RejectTrade.php b/app-modules/economy/src/Trade/Actions/RejectTrade.php new file mode 100644 index 00000000..d829a746 --- /dev/null +++ b/app-modules/economy/src/Trade/Actions/RejectTrade.php @@ -0,0 +1,35 @@ +findOrFail($tradeId); + + if (!$trade->isPending()) { + throw InvalidTradeException::notPending($tradeId); + } + + if ($trade->receiver_character_id !== $receiverCharacterId) { + throw InvalidTradeException::notAuthorized(); + } + + $trade->update([ + 'status' => TradeStatus::Rejected, + 'resolved_at' => now(), + ]); + + return $trade->fresh(); + } +} diff --git a/app-modules/economy/src/Trade/DTOs/CreateTradeDTO.php b/app-modules/economy/src/Trade/DTOs/CreateTradeDTO.php new file mode 100644 index 00000000..456e8022 --- /dev/null +++ b/app-modules/economy/src/Trade/DTOs/CreateTradeDTO.php @@ -0,0 +1,20 @@ + $offeredItemIds + * @param array $requestedItemIds + */ + public function __construct( + public int $tenantId, + public string $initiatorCharacterId, + public string $receiverCharacterId, + public array $offeredItemIds, + public array $requestedItemIds, + ) {} +} diff --git a/app-modules/economy/src/Trade/Enums/TradeDirection.php b/app-modules/economy/src/Trade/Enums/TradeDirection.php new file mode 100644 index 00000000..e0a7046f --- /dev/null +++ b/app-modules/economy/src/Trade/Enums/TradeDirection.php @@ -0,0 +1,11 @@ + */ + use HasFactory; + use HasUuids; + + protected $fillable = [ + 'tenant_id', + 'initiator_character_id', + 'receiver_character_id', + 'status', + 'resolved_at', + ]; + + /** + * @return BelongsTo + */ + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } + + /** + * @return BelongsTo + */ + public function initiator(): BelongsTo + { + return $this->belongsTo(Character::class, 'initiator_character_id'); + } + + /** + * @return BelongsTo + */ + public function receiver(): BelongsTo + { + return $this->belongsTo(Character::class, 'receiver_character_id'); + } + + /** + * @return HasMany + */ + public function items(): HasMany + { + return $this->hasMany(TradeItem::class); + } + + public function isPending(): bool + { + return $this->status === TradeStatus::Pending; + } + + protected static function newFactory(): TradeFactory + { + return TradeFactory::new(); + } + + protected function casts(): array + { + return [ + 'status' => TradeStatus::class, + 'resolved_at' => 'datetime', + ]; + } +} diff --git a/app-modules/economy/src/Trade/Models/TradeItem.php b/app-modules/economy/src/Trade/Models/TradeItem.php new file mode 100644 index 00000000..3a13a988 --- /dev/null +++ b/app-modules/economy/src/Trade/Models/TradeItem.php @@ -0,0 +1,64 @@ + */ + use HasFactory; + use HasUuids; + + public const UPDATED_AT = null; + + protected $fillable = [ + 'trade_id', + 'character_item_id', + 'direction', + ]; + + /** + * @return BelongsTo + */ + public function trade(): BelongsTo + { + return $this->belongsTo(Trade::class); + } + + /** + * @return BelongsTo + */ + public function characterItem(): BelongsTo + { + return $this->belongsTo(CharacterItem::class); + } + + protected static function newFactory(): TradeItemFactory + { + return TradeItemFactory::new(); + } + + protected function casts(): array + { + return [ + 'direction' => TradeDirection::class, + ]; + } +} diff --git a/app-modules/gamification/database/factories/CharacterEquipmentFactory.php b/app-modules/gamification/database/factories/CharacterEquipmentFactory.php new file mode 100644 index 00000000..22dcf8a2 --- /dev/null +++ b/app-modules/gamification/database/factories/CharacterEquipmentFactory.php @@ -0,0 +1,32 @@ + + */ +final class CharacterEquipmentFactory extends Factory +{ + protected $model = CharacterEquipment::class; + + public function definition(): array + { + return [ + 'id' => fake()->uuid(), + 'character_id' => Character::factory(), + 'slot_id' => ItemSlot::factory(), + 'character_item_id' => CharacterItem::factory(), + 'tenant_id' => Tenant::factory(), + 'equipped_at' => now(), + ]; + } +} diff --git a/app-modules/gamification/database/factories/CharacterItemFactory.php b/app-modules/gamification/database/factories/CharacterItemFactory.php new file mode 100644 index 00000000..31ddea2e --- /dev/null +++ b/app-modules/gamification/database/factories/CharacterItemFactory.php @@ -0,0 +1,32 @@ + + */ +final class CharacterItemFactory extends Factory +{ + protected $model = CharacterItem::class; + + public function definition(): array + { + return [ + 'id' => fake()->uuid(), + 'character_id' => Character::factory(), + 'item_id' => Item::factory(), + 'tenant_id' => Tenant::factory(), + 'acquired_via' => fake()->randomElement(AcquisitionMethod::cases()), + 'acquired_at' => now(), + ]; + } +} diff --git a/app-modules/gamification/database/factories/ItemFactory.php b/app-modules/gamification/database/factories/ItemFactory.php new file mode 100644 index 00000000..240dfd56 --- /dev/null +++ b/app-modules/gamification/database/factories/ItemFactory.php @@ -0,0 +1,39 @@ + + */ +final class ItemFactory extends Factory +{ + protected $model = Item::class; + + public function definition(): array + { + return [ + 'id' => fake()->uuid(), + 'tenant_id' => Tenant::factory(), + 'slot_id' => ItemSlot::factory(), + 'rarity_id' => ItemRarity::factory(), + 'name' => fake()->word(), + 'slug' => fake()->unique()->slug(2), + 'description' => fake()->sentence(), + 'is_tradeable' => true, + 'is_purchasable' => false, + 'price' => null, + 'drop_rate' => null, + 'level_required' => 0, + 'active' => true, + 'metadata' => null, + ]; + } +} diff --git a/app-modules/gamification/database/factories/ItemRarityFactory.php b/app-modules/gamification/database/factories/ItemRarityFactory.php new file mode 100644 index 00000000..27572dfe --- /dev/null +++ b/app-modules/gamification/database/factories/ItemRarityFactory.php @@ -0,0 +1,28 @@ + + */ +final class ItemRarityFactory extends Factory +{ + protected $model = ItemRarity::class; + + public function definition(): array + { + return [ + 'tenant_id' => Tenant::factory(), + 'name' => fake()->word(), + 'slug' => fake()->unique()->slug(2), + 'color' => fake()->hexColor(), + 'drop_weight' => fake()->numberBetween(1, 1000), + ]; + } +} diff --git a/app-modules/gamification/database/factories/ItemSlotFactory.php b/app-modules/gamification/database/factories/ItemSlotFactory.php new file mode 100644 index 00000000..432e30ad --- /dev/null +++ b/app-modules/gamification/database/factories/ItemSlotFactory.php @@ -0,0 +1,29 @@ + + */ +final class ItemSlotFactory extends Factory +{ + protected $model = ItemSlot::class; + + public function definition(): array + { + return [ + 'tenant_id' => Tenant::factory(), + 'name' => fake()->word(), + 'slug' => fake()->unique()->slug(2), + 'slot_type' => fake()->randomElement(SlotType::cases()), + 'display_order' => fake()->numberBetween(0, 10), + ]; + } +} diff --git a/app-modules/gamification/database/migrations/2026_03_25_000001_create_item_slots_table.php b/app-modules/gamification/database/migrations/2026_03_25_000001_create_item_slots_table.php new file mode 100644 index 00000000..2f243a8f --- /dev/null +++ b/app-modules/gamification/database/migrations/2026_03_25_000001_create_item_slots_table.php @@ -0,0 +1,30 @@ +id(); + $table->foreignId('tenant_id')->constrained('tenants')->nullOnDelete(); + $table->string('name'); + $table->string('slug'); + $table->string('slot_type', 50)->default('equipment'); + $table->integer('display_order')->default(0); + $table->timestamps(); + + $table->unique(['tenant_id', 'slug']); + }); + } + + public function down(): void + { + Schema::dropIfExists('item_slots'); + } +}; diff --git a/app-modules/gamification/database/migrations/2026_03_25_000002_create_item_rarities_table.php b/app-modules/gamification/database/migrations/2026_03_25_000002_create_item_rarities_table.php new file mode 100644 index 00000000..ee6bfbc2 --- /dev/null +++ b/app-modules/gamification/database/migrations/2026_03_25_000002_create_item_rarities_table.php @@ -0,0 +1,30 @@ +id(); + $table->foreignId('tenant_id')->constrained('tenants')->nullOnDelete(); + $table->string('name'); + $table->string('slug'); + $table->string('color', 7); + $table->integer('drop_weight')->default(100); + $table->timestamps(); + + $table->unique(['tenant_id', 'slug']); + }); + } + + public function down(): void + { + Schema::dropIfExists('item_rarities'); + } +}; diff --git a/app-modules/gamification/database/migrations/2026_03_25_000003_create_items_table.php b/app-modules/gamification/database/migrations/2026_03_25_000003_create_items_table.php new file mode 100644 index 00000000..5f557109 --- /dev/null +++ b/app-modules/gamification/database/migrations/2026_03_25_000003_create_items_table.php @@ -0,0 +1,41 @@ +uuid('id')->primary(); + $table->foreignId('tenant_id')->constrained('tenants')->nullOnDelete(); + $table->foreignId('slot_id')->constrained('item_slots'); + $table->foreignId('rarity_id')->constrained('item_rarities'); + $table->string('name'); + $table->string('slug'); + $table->text('description')->nullable(); + $table->boolean('is_tradeable')->default(true); + $table->boolean('is_purchasable')->default(false); + $table->integer('price')->nullable(); + $table->decimal('drop_rate', 5, 4)->nullable(); + $table->integer('level_required')->default(0); + $table->boolean('active')->default(true); + $table->jsonb('metadata')->nullable(); + $table->timestamps(); + + $table->unique(['tenant_id', 'slug']); + $table->index('slot_id'); + $table->index('rarity_id'); + $table->index(['tenant_id', 'active']); + }); + } + + public function down(): void + { + Schema::dropIfExists('items'); + } +}; diff --git a/app-modules/gamification/database/migrations/2026_03_25_000004_create_character_items_table.php b/app-modules/gamification/database/migrations/2026_03_25_000004_create_character_items_table.php new file mode 100644 index 00000000..38f53b83 --- /dev/null +++ b/app-modules/gamification/database/migrations/2026_03_25_000004_create_character_items_table.php @@ -0,0 +1,32 @@ +uuid('id')->primary(); + $table->foreignUuid('character_id')->constrained('characters')->cascadeOnDelete(); + $table->foreignUuid('item_id')->constrained('items'); + $table->foreignId('tenant_id')->constrained('tenants')->nullOnDelete(); + $table->string('acquired_via', 50); + $table->timestamp('acquired_at'); + $table->timestamps(); + + $table->unique(['character_id', 'item_id']); + $table->index('character_id'); + $table->index('item_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('character_items'); + } +}; diff --git a/app-modules/gamification/database/migrations/2026_03_25_000005_create_character_equipment_table.php b/app-modules/gamification/database/migrations/2026_03_25_000005_create_character_equipment_table.php new file mode 100644 index 00000000..a6aab2a2 --- /dev/null +++ b/app-modules/gamification/database/migrations/2026_03_25_000005_create_character_equipment_table.php @@ -0,0 +1,31 @@ +uuid('id')->primary(); + $table->foreignUuid('character_id')->constrained('characters')->cascadeOnDelete(); + $table->foreignId('slot_id')->constrained('item_slots'); + $table->foreignUuid('character_item_id')->constrained('character_items')->cascadeOnDelete(); + $table->foreignId('tenant_id')->constrained('tenants')->nullOnDelete(); + $table->timestamp('equipped_at'); + $table->timestamps(); + + $table->unique(['character_id', 'slot_id']); + $table->index('character_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('character_equipment'); + } +}; diff --git a/app-modules/gamification/src/Character/Equipment/Actions/EquipItem.php b/app-modules/gamification/src/Character/Equipment/Actions/EquipItem.php new file mode 100644 index 00000000..f649db14 --- /dev/null +++ b/app-modules/gamification/src/Character/Equipment/Actions/EquipItem.php @@ -0,0 +1,52 @@ +where('id', $dto->characterItemId) + ->where('character_id', $dto->characterId) + ->first(); + + if (!$characterItem) { + throw EquipmentException::itemNotInInventory($dto->characterItemId); + } + + $item = $characterItem->item; + $character = Character::query()->findOrFail($dto->characterId); + + if ($character->level < $item->level_required) { + throw ItemException::levelTooLow($character->level, $item->level_required); + } + + // Auto-unequip existing item in the same slot (swap) + CharacterEquipment::query() + ->where('character_id', $dto->characterId) + ->where('slot_id', $item->slot_id) + ->delete(); + + return CharacterEquipment::query()->create([ + 'character_id' => $dto->characterId, + 'slot_id' => $item->slot_id, + 'character_item_id' => $characterItem->id, + 'tenant_id' => $characterItem->tenant_id, + 'equipped_at' => now(), + ]); + } +} diff --git a/app-modules/gamification/src/Character/Equipment/Actions/UnequipItem.php b/app-modules/gamification/src/Character/Equipment/Actions/UnequipItem.php new file mode 100644 index 00000000..07626d1e --- /dev/null +++ b/app-modules/gamification/src/Character/Equipment/Actions/UnequipItem.php @@ -0,0 +1,28 @@ +where('character_id', $characterId) + ->where('slot_id', $slotId) + ->first(); + + if (!$equipment) { + throw EquipmentException::notEquipped($characterId, $slotId); + } + + $equipment->delete(); + } +} diff --git a/app-modules/gamification/src/Character/Equipment/DTOs/EquipItemDTO.php b/app-modules/gamification/src/Character/Equipment/DTOs/EquipItemDTO.php new file mode 100644 index 00000000..1cefcee9 --- /dev/null +++ b/app-modules/gamification/src/Character/Equipment/DTOs/EquipItemDTO.php @@ -0,0 +1,13 @@ + */ + use HasFactory; + use HasUuids; + + protected $table = 'character_equipment'; + + protected $fillable = [ + 'id', + 'character_id', + 'slot_id', + 'character_item_id', + 'tenant_id', + 'equipped_at', + ]; + + /** + * @return BelongsTo + */ + public function character(): BelongsTo + { + return $this->belongsTo(Character::class); + } + + /** + * @return BelongsTo + */ + public function slot(): BelongsTo + { + return $this->belongsTo(ItemSlot::class, 'slot_id'); + } + + /** + * @return BelongsTo + */ + public function characterItem(): BelongsTo + { + return $this->belongsTo(CharacterItem::class, 'character_item_id'); + } + + /** + * @return BelongsTo + */ + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } + + protected static function newFactory(): CharacterEquipmentFactory + { + return CharacterEquipmentFactory::new(); + } + + protected function casts(): array + { + return [ + 'equipped_at' => 'datetime', + ]; + } +} diff --git a/app-modules/gamification/src/Character/Inventory/Actions/AddItemToInventory.php b/app-modules/gamification/src/Character/Inventory/Actions/AddItemToInventory.php new file mode 100644 index 00000000..02027ebc --- /dev/null +++ b/app-modules/gamification/src/Character/Inventory/Actions/AddItemToInventory.php @@ -0,0 +1,49 @@ +findOrFail($dto->itemId); + + if (!$item->active) { + throw ItemException::notActive($dto->itemId); + } + + $character = Character::query()->findOrFail($dto->characterId); + + if ($character->level < $item->level_required) { + throw ItemException::levelTooLow($character->level, $item->level_required); + } + + $exists = CharacterItem::query() + ->where('character_id', $dto->characterId) + ->where('item_id', $dto->itemId) + ->exists(); + + if ($exists) { + throw ItemException::alreadyOwned($dto->characterId, $dto->itemId); + } + + return CharacterItem::query()->create([ + 'character_id' => $dto->characterId, + 'item_id' => $dto->itemId, + 'tenant_id' => $dto->tenantId, + 'acquired_via' => $dto->acquiredVia, + 'acquired_at' => now(), + ]); + } +} diff --git a/app-modules/gamification/src/Character/Inventory/DTOs/AddItemToInventoryDTO.php b/app-modules/gamification/src/Character/Inventory/DTOs/AddItemToInventoryDTO.php new file mode 100644 index 00000000..589c42ff --- /dev/null +++ b/app-modules/gamification/src/Character/Inventory/DTOs/AddItemToInventoryDTO.php @@ -0,0 +1,17 @@ + */ + use HasFactory; + use HasUuids; + + protected $table = 'character_items'; + + protected $fillable = [ + 'id', + 'character_id', + 'item_id', + 'tenant_id', + 'acquired_via', + 'acquired_at', + ]; + + /** + * @return BelongsTo + */ + public function character(): BelongsTo + { + return $this->belongsTo(Character::class); + } + + /** + * @return BelongsTo + */ + public function item(): BelongsTo + { + return $this->belongsTo(Item::class); + } + + /** + * @return BelongsTo + */ + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } + + /** + * @return HasOne + */ + public function equipment(): HasOne + { + return $this->hasOne(CharacterEquipment::class, 'character_item_id'); + } + + public function isEquipped(): bool + { + return $this->equipment()->exists(); + } + + protected static function newFactory(): CharacterItemFactory + { + return CharacterItemFactory::new(); + } + + protected function casts(): array + { + return [ + 'acquired_via' => AcquisitionMethod::class, + 'acquired_at' => 'datetime', + ]; + } +} diff --git a/app-modules/gamification/src/Character/Models/Character.php b/app-modules/gamification/src/Character/Models/Character.php index 38a01ad3..6e843596 100644 --- a/app-modules/gamification/src/Character/Models/Character.php +++ b/app-modules/gamification/src/Character/Models/Character.php @@ -7,6 +7,8 @@ use Carbon\Carbon; use He4rt\Economy\Concerns\HasWallet; use He4rt\Gamification\Badge\Models\Badge; +use He4rt\Gamification\Character\Equipment\Models\CharacterEquipment; +use He4rt\Gamification\Character\Inventory\Models\CharacterItem; use He4rt\Gamification\Database\Factories\CharacterFactory; use He4rt\Identity\Tenant\Models\Tenant; use He4rt\Identity\User\Models\User; @@ -110,6 +112,22 @@ public function pastSeasons(): HasMany return $this->hasMany(PastSeason::class); } + /** + * @return HasMany + */ + public function inventory(): HasMany + { + return $this->hasMany(CharacterItem::class); + } + + /** + * @return HasMany + */ + public function equipment(): HasMany + { + return $this->hasMany(CharacterEquipment::class); + } + protected static function newFactory(): CharacterFactory { return CharacterFactory::new(); diff --git a/app-modules/gamification/src/Item/Enums/AcquisitionMethod.php b/app-modules/gamification/src/Item/Enums/AcquisitionMethod.php new file mode 100644 index 00000000..6e522260 --- /dev/null +++ b/app-modules/gamification/src/Item/Enums/AcquisitionMethod.php @@ -0,0 +1,13 @@ +|null $metadata + */ +final class Item extends Model +{ + /** @use HasFactory */ + use HasFactory; + use HasUuids; + + protected $table = 'items'; + + protected $fillable = [ + 'id', + 'tenant_id', + 'slot_id', + 'rarity_id', + 'name', + 'slug', + 'description', + 'is_tradeable', + 'is_purchasable', + 'price', + 'drop_rate', + 'level_required', + 'active', + 'metadata', + ]; + + /** + * @return BelongsTo + */ + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } + + /** + * @return BelongsTo + */ + public function slot(): BelongsTo + { + return $this->belongsTo(ItemSlot::class, 'slot_id'); + } + + /** + * @return BelongsTo + */ + public function rarity(): BelongsTo + { + return $this->belongsTo(ItemRarity::class, 'rarity_id'); + } + + /** + * @return HasMany + */ + public function characterItems(): HasMany + { + return $this->hasMany(CharacterItem::class); + } + + protected static function newFactory(): ItemFactory + { + return ItemFactory::new(); + } + + protected function casts(): array + { + return [ + 'is_tradeable' => 'boolean', + 'is_purchasable' => 'boolean', + 'active' => 'boolean', + 'metadata' => 'array', + 'drop_rate' => 'decimal:4', + ]; + } +} diff --git a/app-modules/gamification/src/Item/Models/ItemRarity.php b/app-modules/gamification/src/Item/Models/ItemRarity.php new file mode 100644 index 00000000..6d3a498b --- /dev/null +++ b/app-modules/gamification/src/Item/Models/ItemRarity.php @@ -0,0 +1,57 @@ + */ + use HasFactory; + + protected $table = 'item_rarities'; + + protected $fillable = [ + 'tenant_id', + 'name', + 'slug', + 'color', + 'drop_weight', + ]; + + /** + * @return BelongsTo + */ + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } + + /** + * @return HasMany + */ + public function items(): HasMany + { + return $this->hasMany(Item::class, 'rarity_id'); + } + + protected static function newFactory(): ItemRarityFactory + { + return ItemRarityFactory::new(); + } +} diff --git a/app-modules/gamification/src/Item/Models/ItemSlot.php b/app-modules/gamification/src/Item/Models/ItemSlot.php new file mode 100644 index 00000000..73f437a6 --- /dev/null +++ b/app-modules/gamification/src/Item/Models/ItemSlot.php @@ -0,0 +1,65 @@ + */ + use HasFactory; + + protected $table = 'item_slots'; + + protected $fillable = [ + 'tenant_id', + 'name', + 'slug', + 'slot_type', + 'display_order', + ]; + + /** + * @return BelongsTo + */ + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } + + /** + * @return HasMany + */ + public function items(): HasMany + { + return $this->hasMany(Item::class, 'slot_id'); + } + + protected static function newFactory(): ItemSlotFactory + { + return ItemSlotFactory::new(); + } + + protected function casts(): array + { + return [ + 'slot_type' => SlotType::class, + ]; + } +} diff --git a/app-modules/gamification/src/Providers/GamificationServiceProvider.php b/app-modules/gamification/src/Providers/GamificationServiceProvider.php index 531d3eca..3ce9e5f7 100644 --- a/app-modules/gamification/src/Providers/GamificationServiceProvider.php +++ b/app-modules/gamification/src/Providers/GamificationServiceProvider.php @@ -5,7 +5,12 @@ namespace He4rt\Gamification\Providers; use He4rt\Gamification\Badge\Models\Badge; +use He4rt\Gamification\Character\Equipment\Models\CharacterEquipment; +use He4rt\Gamification\Character\Inventory\Models\CharacterItem; use He4rt\Gamification\Character\Models\Character; +use He4rt\Gamification\Item\Models\Item; +use He4rt\Gamification\Item\Models\ItemRarity; +use He4rt\Gamification\Item\Models\ItemSlot; use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Support\ServiceProvider; @@ -20,6 +25,11 @@ public function boot(): void Relation::morphMap([ 'character' => Character::class, 'badge' => Badge::class, + 'item' => Item::class, + 'item_slot' => ItemSlot::class, + 'item_rarity' => ItemRarity::class, + 'character_item' => CharacterItem::class, + 'character_equipment' => CharacterEquipment::class, ]); } } diff --git a/app-modules/gamification/tests/Feature/Character/AddItemToInventoryTest.php b/app-modules/gamification/tests/Feature/Character/AddItemToInventoryTest.php new file mode 100644 index 00000000..c4868aaf --- /dev/null +++ b/app-modules/gamification/tests/Feature/Character/AddItemToInventoryTest.php @@ -0,0 +1,96 @@ +create(); + $slot = ItemSlot::factory()->recycle($tenant)->create(); + $rarity = ItemRarity::factory()->recycle($tenant)->create(); + $item = Item::factory()->recycle([$tenant, $slot, $rarity])->create([ + 'level_required' => 0, + 'active' => true, + ]); + $character = Character::factory()->recycle($tenant)->create(['experience' => 500]); + + $dto = new AddItemToInventoryDTO( + characterId: $character->id, + itemId: $item->id, + tenantId: $tenant->id, + acquiredVia: AcquisitionMethod::Drop, + ); + + $result = resolve(AddItemToInventory::class)->handle($dto); + + expect($result->character_id)->toBe($character->id) + ->and($result->item_id)->toBe($item->id) + ->and($result->acquired_via)->toBe(AcquisitionMethod::Drop); + + $this->assertDatabaseHas('character_items', [ + 'character_id' => $character->id, + 'item_id' => $item->id, + 'acquired_via' => 'drop', + ]); +}); + +test('cannot add inactive item to inventory', function (): void { + $tenant = Tenant::factory()->create(); + $item = Item::factory()->recycle($tenant)->create(['active' => false]); + $character = Character::factory()->recycle($tenant)->create(); + + $dto = new AddItemToInventoryDTO( + characterId: $character->id, + itemId: $item->id, + tenantId: $tenant->id, + acquiredVia: AcquisitionMethod::Purchase, + ); + + resolve(AddItemToInventory::class)->handle($dto); +})->throws(ItemException::class); + +test('cannot add item when character level is too low', function (): void { + $tenant = Tenant::factory()->create(); + $item = Item::factory()->recycle($tenant)->create([ + 'level_required' => 50, + 'active' => true, + ]); + $character = Character::factory()->recycle($tenant)->create(['experience' => 0]); + + $dto = new AddItemToInventoryDTO( + characterId: $character->id, + itemId: $item->id, + tenantId: $tenant->id, + acquiredVia: AcquisitionMethod::Drop, + ); + + resolve(AddItemToInventory::class)->handle($dto); +})->throws(ItemException::class); + +test('cannot add duplicate item to inventory', function (): void { + $tenant = Tenant::factory()->create(); + $item = Item::factory()->recycle($tenant)->create([ + 'active' => true, + 'level_required' => 0, + ]); + $character = Character::factory()->recycle($tenant)->create(); + + $dto = new AddItemToInventoryDTO( + characterId: $character->id, + itemId: $item->id, + tenantId: $tenant->id, + acquiredVia: AcquisitionMethod::Reward, + ); + + $action = resolve(AddItemToInventory::class); + $action->handle($dto); + $action->handle($dto); +})->throws(ItemException::class); diff --git a/app-modules/gamification/tests/Feature/Character/EquipItemTest.php b/app-modules/gamification/tests/Feature/Character/EquipItemTest.php new file mode 100644 index 00000000..e3d9d9e9 --- /dev/null +++ b/app-modules/gamification/tests/Feature/Character/EquipItemTest.php @@ -0,0 +1,67 @@ +create(); + $slot = ItemSlot::factory()->recycle($tenant)->create(); + $rarity = ItemRarity::factory()->recycle($tenant)->create(); + $item = Item::factory()->recycle([$tenant, $slot, $rarity])->create(['level_required' => 0]); + $character = Character::factory()->recycle($tenant)->create(); + $characterItem = CharacterItem::factory()->recycle([$character, $item, $tenant])->create([ + 'acquired_via' => AcquisitionMethod::Drop, + ]); + + $dto = new EquipItemDTO( + characterId: $character->id, + characterItemId: $characterItem->id, + ); + + $result = resolve(EquipItem::class)->handle($dto); + + $this->assertDatabaseHas('character_equipment', [ + 'character_id' => $character->id, + 'slot_id' => $slot->id, + 'character_item_id' => $characterItem->id, + ]); +}); + +test('equipping item swaps existing item in same slot', function (): void { + $tenant = Tenant::factory()->create(); + $slot = ItemSlot::factory()->recycle($tenant)->create(); + $rarity = ItemRarity::factory()->recycle($tenant)->create(); + $item1 = Item::factory()->recycle([$tenant, $slot, $rarity])->create(['level_required' => 0]); + $item2 = Item::factory()->recycle([$tenant, $slot, $rarity])->create(['level_required' => 0]); + $character = Character::factory()->recycle($tenant)->create(); + $ci1 = CharacterItem::factory()->recycle([$character, $tenant])->create(['item_id' => $item1->id]); + $ci2 = CharacterItem::factory()->recycle([$character, $tenant])->create(['item_id' => $item2->id]); + + $action = resolve(EquipItem::class); + $action->handle(new EquipItemDTO($character->id, $ci1->id)); + $action->handle(new EquipItemDTO($character->id, $ci2->id)); + + $this->assertDatabaseHas('character_equipment', ['character_item_id' => $ci2->id]); + $this->assertDatabaseMissing('character_equipment', ['character_item_id' => $ci1->id]); +}); + +test('cannot equip item not in inventory', function (): void { + $character = Character::factory()->create(); + + $dto = new EquipItemDTO( + characterId: $character->id, + characterItemId: fake()->uuid(), + ); + + resolve(EquipItem::class)->handle($dto); +})->throws(EquipmentException::class); diff --git a/app-modules/gamification/tests/Feature/Character/UnequipItemTest.php b/app-modules/gamification/tests/Feature/Character/UnequipItemTest.php new file mode 100644 index 00000000..f3d15a2c --- /dev/null +++ b/app-modules/gamification/tests/Feature/Character/UnequipItemTest.php @@ -0,0 +1,48 @@ +create(); + $slot = ItemSlot::factory()->recycle($tenant)->create(); + $rarity = ItemRarity::factory()->recycle($tenant)->create(); + $item = Item::factory()->recycle([$tenant, $slot, $rarity])->create(); + $character = Character::factory()->recycle($tenant)->create(); + $ci = CharacterItem::factory()->recycle([$character, $item, $tenant])->create([ + 'acquired_via' => AcquisitionMethod::Drop, + ]); + CharacterEquipment::factory()->recycle([$character, $slot, $tenant])->create([ + 'character_item_id' => $ci->id, + ]); + + resolve(UnequipItem::class)->handle($character->id, $slot->id); + + $this->assertDatabaseMissing('character_equipment', [ + 'character_id' => $character->id, + 'slot_id' => $slot->id, + ]); + + // Item remains in inventory + $this->assertDatabaseHas('character_items', [ + 'character_id' => $character->id, + 'item_id' => $item->id, + ]); +}); + +test('cannot unequip empty slot', function (): void { + $character = Character::factory()->create(); + $slot = ItemSlot::factory()->create(); + + resolve(UnequipItem::class)->handle($character->id, $slot->id); +})->throws(EquipmentException::class);