-
Notifications
You must be signed in to change notification settings - Fork 13
feat: inventory, equipment, shop and trade system (V1) #193
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: 4.x
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,41 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| namespace He4rt\Economy\Database\Factories; | ||
|
|
||
| use He4rt\Economy\Shop\Models\ShopListing; | ||
| use He4rt\Gamification\Item\Models\Item; | ||
| use He4rt\Identity\Tenant\Models\Tenant; | ||
| use Illuminate\Database\Eloquent\Factories\Factory; | ||
|
|
||
| /** | ||
| * @extends Factory<ShopListing> | ||
| */ | ||
| 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]); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,54 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| namespace He4rt\Economy\Database\Factories; | ||
|
|
||
| use He4rt\Economy\Trade\Enums\TradeStatus; | ||
| use He4rt\Economy\Trade\Models\Trade; | ||
| use He4rt\Gamification\Character\Models\Character; | ||
| use He4rt\Identity\Tenant\Models\Tenant; | ||
| use Illuminate\Database\Eloquent\Factories\Factory; | ||
|
|
||
| /** | ||
| * @extends Factory<Trade> | ||
| */ | ||
| 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(), | ||
| ]); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,33 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| namespace He4rt\Economy\Database\Factories; | ||
|
|
||
| use He4rt\Economy\Trade\Enums\TradeDirection; | ||
| use He4rt\Economy\Trade\Models\Trade; | ||
| use He4rt\Economy\Trade\Models\TradeItem; | ||
| use He4rt\Gamification\Character\Inventory\Models\CharacterItem; | ||
| use Illuminate\Database\Eloquent\Factories\Factory; | ||
|
|
||
| /** | ||
| * @extends Factory<TradeItem> | ||
| */ | ||
| 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]); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,33 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| use Illuminate\Database\Migrations\Migration; | ||
| use Illuminate\Database\Schema\Blueprint; | ||
| use Illuminate\Support\Facades\Schema; | ||
|
|
||
| return new class extends Migration | ||
| { | ||
| public function up(): void | ||
| { | ||
| Schema::create('shop_listings', function (Blueprint $table): void { | ||
| $table->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'); | ||
| } | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| use Illuminate\Database\Migrations\Migration; | ||
| use Illuminate\Database\Schema\Blueprint; | ||
| use Illuminate\Support\Facades\Schema; | ||
|
|
||
| return new class extends Migration | ||
| { | ||
| public function up(): void | ||
| { | ||
| Schema::create('trades', function (Blueprint $table): void { | ||
| $table->uuid('id')->primary(); | ||
| $table->foreignId('tenant_id')->constrained('tenants')->nullOnDelete(); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: cat -n app-modules/economy/database/migrations/2026_03_25_100002_create_trades_table.phpRepository: he4rt/heartdevs.com Length of output: 1310 🏁 Script executed: rg "nullOnDelete\(\)" --type phpRepository: he4rt/heartdevs.com Length of output: 1763 🏁 Script executed: grep -A1 "foreignId('tenant_id')" app-modules/gamification/database/migrations/2026_03_25_000004_create_character_items_table.php | head -5Repository: he4rt/heartdevs.com Length of output: 194 🏁 Script executed: grep -A1 "foreignId('tenant_id')" app-modules/economy/database/migrations/2026_03_25_100001_create_shop_listings_table.php | head -5Repository: he4rt/heartdevs.com Length of output: 212 Add Using - $table->foreignId('tenant_id')->constrained('tenants')->nullOnDelete();
+ $table->foreignId('tenant_id')->nullable()->constrained('tenants')->nullOnDelete();Note: This issue affects multiple tables in the codebase (character_items, item_rarities, item_slots, character_equipment, items, shop_listings, and others). 🤖 Prompt for AI Agents |
||
| $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'); | ||
| } | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| use Illuminate\Database\Migrations\Migration; | ||
| use Illuminate\Database\Schema\Blueprint; | ||
| use Illuminate\Support\Facades\Schema; | ||
|
|
||
| return new class extends Migration | ||
| { | ||
| public function up(): void | ||
| { | ||
| Schema::create('trade_items', function (Blueprint $table): void { | ||
| $table->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'); | ||
| } | ||
| }; |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,88 @@ | ||||||||||||||||||||||||||
| <?php | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| declare(strict_types=1); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| namespace He4rt\Economy\Shop\Actions; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| use He4rt\Economy\Actions\Debit; | ||||||||||||||||||||||||||
| use He4rt\Economy\DTOs\DebitDTO; | ||||||||||||||||||||||||||
| use He4rt\Economy\Exceptions\InsufficientBalanceException; | ||||||||||||||||||||||||||
| use He4rt\Economy\Shop\DTOs\PurchaseItemDTO; | ||||||||||||||||||||||||||
| use He4rt\Economy\Shop\Exceptions\ItemAlreadyOwnedException; | ||||||||||||||||||||||||||
| use He4rt\Economy\Shop\Exceptions\ItemNotAvailableException; | ||||||||||||||||||||||||||
| use He4rt\Economy\Shop\Exceptions\ItemOutOfStockException; | ||||||||||||||||||||||||||
| use He4rt\Economy\Shop\Exceptions\LevelRequirementNotMetException; | ||||||||||||||||||||||||||
| use He4rt\Economy\Shop\Models\ShopListing; | ||||||||||||||||||||||||||
| use He4rt\Gamification\Character\Inventory\Actions\AddItemToInventory; | ||||||||||||||||||||||||||
| use He4rt\Gamification\Character\Inventory\DTOs\AddItemToInventoryDTO; | ||||||||||||||||||||||||||
| use He4rt\Gamification\Character\Inventory\Models\CharacterItem; | ||||||||||||||||||||||||||
| use He4rt\Gamification\Character\Models\Character; | ||||||||||||||||||||||||||
| use He4rt\Gamification\Item\Enums\AcquisitionMethod; | ||||||||||||||||||||||||||
| use Illuminate\Support\Facades\DB; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| final readonly class PurchaseItem | ||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||
| public function __construct( | ||||||||||||||||||||||||||
| private Debit $debit, | ||||||||||||||||||||||||||
| private AddItemToInventory $addItemToInventory, | ||||||||||||||||||||||||||
| ) {} | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||
| * @throws ItemNotAvailableException | ||||||||||||||||||||||||||
| * @throws ItemOutOfStockException | ||||||||||||||||||||||||||
| * @throws ItemAlreadyOwnedException | ||||||||||||||||||||||||||
| * @throws LevelRequirementNotMetException | ||||||||||||||||||||||||||
| * @throws InsufficientBalanceException | ||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||
| public function handle(PurchaseItemDTO $dto): CharacterItem | ||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||
| $character = Character::query()->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); | ||||||||||||||||||||||||||
|
Check failure on line 62 in app-modules/economy/src/Shop/Actions/PurchaseItem.php
|
||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| $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, | ||||||||||||||||||||||||||
|
Check failure on line 81 in app-modules/economy/src/Shop/Actions/PurchaseItem.php
|
||||||||||||||||||||||||||
| itemId: $item->id, | ||||||||||||||||||||||||||
| tenantId: $listing->tenant_id, | ||||||||||||||||||||||||||
| acquiredVia: AcquisitionMethod::Purchase, | ||||||||||||||||||||||||||
| )); | ||||||||||||||||||||||||||
|
Comment on lines
+80
to
+85
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Type mismatch: PHPStan confirms that 🐛 Proposed fix return $this->addItemToInventory->handle(new AddItemToInventoryDTO(
- characterId: $character->id,
- itemId: $item->id,
+ characterId: (string) $character->id,
+ itemId: (string) $item->id,
tenantId: $listing->tenant_id,
acquiredVia: AcquisitionMethod::Purchase,
));📝 Committable suggestion
Suggested change
🧰 Tools🪛 GitHub Check: Perform Phpstan Check / Run[failure] 81-81: 🤖 Prompt for AI Agents |
||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| namespace He4rt\Economy\Shop\DTOs; | ||
|
|
||
| final readonly class PurchaseItemDTO | ||
| { | ||
| public function __construct( | ||
| public string $characterId, | ||
| public string $shopListingId, | ||
| ) {} | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| namespace He4rt\Economy\Shop\Exceptions; | ||
|
|
||
| use Exception; | ||
|
|
||
| final class ItemAlreadyOwnedException extends Exception | ||
| { | ||
| public static function forCharacter(string $characterId, string $itemId): self | ||
| { | ||
| return new self( | ||
| sprintf('Character %s already owns item %s.', $characterId, $itemId) | ||
| ); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| namespace He4rt\Economy\Shop\Exceptions; | ||
|
|
||
| use Exception; | ||
|
|
||
| final class ItemNotAvailableException extends Exception | ||
| { | ||
| public static function forListing(string $listingId): self | ||
| { | ||
| return new self( | ||
| sprintf('Shop listing %s is not currently available.', $listingId) | ||
| ); | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
Repository: he4rt/heartdevs.com
Length of output: 149
🏁 Script executed:
Repository: he4rt/heartdevs.com
Length of output: 1070
Make
tenant_idnullable when usingnullOnDelete().The column is configured with
nullOnDelete()but is non-nullable. When a tenant is deleted, the database constraint will fail because it cannot set the column to NULL. This causes referential integrity violations.🔧 Proposed fix
📝 Committable suggestion
🤖 Prompt for AI Agents