Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@

All notable changes to this project will be documented in this file.

## [1.3.0] - 2026-02-18

### Added
- AI Card Suggestion module for supervisors: generate card data from game context, re-roll card abilities with tone selection, deck synergy analytics report, and one-click card swap suggestions

## [1.2.0] - 2026-02-18

### Added
Expand Down
16 changes: 16 additions & 0 deletions app/Exceptions/AiSuggestionException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php
/**
* Webtech-solutions 2025, All rights reserved.
*/

namespace App\Exceptions;

use Exception;

class AiSuggestionException extends Exception
{
public function __construct(string $message = 'AI suggestion failed', int $code = 0, ?\Throwable $previous = null)
{
parent::__construct($message, $code, $previous);
}
}
67 changes: 67 additions & 0 deletions app/Filament/Resources/CardResource/Pages/CreateCard.php
Original file line number Diff line number Diff line change
@@ -1,9 +1,18 @@
<?php
/**
* Webtech-solutions 2025, All rights reserved.
*/

namespace App\Filament\Resources\CardResource\Pages;

use App\Exceptions\AiSuggestionException;
use App\Filament\Resources\CardResource;
use App\Models\CardType;
use App\Models\Game;
use App\Services\Ai\CardSuggestionService;
use Filament\Actions;
use Filament\Forms;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\CreateRecord;

class CreateCard extends CreateRecord
Expand All @@ -15,4 +24,62 @@ protected function mutateFormDataBeforeCreate(array $data): array
$data['user_id'] = auth()->id();
return $data;
}

protected function getHeaderActions(): array
{
return [
Actions\Action::make('aiSuggest')
->label('AI Suggest')
->icon('heroicon-o-sparkles')
->color('warning')
->visible(fn () => auth()->user()?->supervisor === true)
->form([
Forms\Components\Select::make('game_id')
->label('Game')
->options(fn () => Game::where('creator_id', auth()->id())->pluck('name', 'id'))
->required()
->searchable()
->default(fn () => $this->form->getState()['game_id'] ?? null),
Forms\Components\Select::make('type_id')
->label('Card Type')
->options(fn () => CardType::where('user_id', auth()->id())->pluck('name', 'id'))
->required()
->searchable()
->default(fn () => $this->form->getState()['type_id'] ?? null),
Forms\Components\TextInput::make('generation_goal')
->label('Generation Goal')
->placeholder('e.g. Aggressive creature with low cost')
->maxLength(255),
])
->action(function (array $data) {
try {
$result = app(CardSuggestionService::class)->suggest(
gameId: (int) $data['game_id'],
cardTypeId: (int) $data['type_id'],
goal: $data['generation_goal'] ?? ''
);

$this->form->fill([
'game_id' => $data['game_id'],
'type_id' => $data['type_id'],
'name' => $result['name'] ?? '',
'card_text' => $result['card_text'] ?? ($result['lore_text'] ?? ''),
'card_data' => $result['card_data'] ?? [],
]);

Notification::make()
->success()
->title('AI Suggestion Applied')
->body('Review the generated fields and save when ready.')
->send();
} catch (AiSuggestionException $e) {
Notification::make()
->danger()
->title('AI Error')
->body($e->getMessage())
->send();
}
}),
];
}
}
116 changes: 116 additions & 0 deletions app/Filament/Resources/CardResource/Pages/EditCard.php
Original file line number Diff line number Diff line change
@@ -1,9 +1,19 @@
<?php
/**
* Webtech-solutions 2025, All rights reserved.
*/

namespace App\Filament\Resources\CardResource\Pages;

use App\Exceptions\AiSuggestionException;
use App\Filament\Resources\CardResource;
use App\Models\CardType;
use App\Models\Game;
use App\Services\Ai\AbilityRerollService;
use App\Services\Ai\CardSuggestionService;
use Filament\Actions;
use Filament\Forms;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\EditRecord;

class EditCard extends EditRecord
Expand All @@ -13,6 +23,112 @@ class EditCard extends EditRecord
protected function getHeaderActions(): array
{
return [
Actions\Action::make('aiSuggest')
->label('AI Suggest')
->icon('heroicon-o-sparkles')
->color('warning')
->visible(fn () => auth()->user()?->supervisor === true)
->form([
Forms\Components\Select::make('game_id')
->label('Game')
->options(fn () => Game::where('creator_id', auth()->id())->pluck('name', 'id'))
->required()
->searchable()
->default(fn () => $this->form->getState()['game_id'] ?? null),
Forms\Components\Select::make('type_id')
->label('Card Type')
->options(fn () => CardType::where('user_id', auth()->id())->pluck('name', 'id'))
->required()
->searchable()
->default(fn () => $this->form->getState()['type_id'] ?? null),
Forms\Components\TextInput::make('generation_goal')
->label('Generation Goal')
->placeholder('e.g. Aggressive creature with low cost')
->maxLength(255),
])
->action(function (array $data) {
try {
$result = app(CardSuggestionService::class)->suggest(
gameId: (int) $data['game_id'],
cardTypeId: (int) $data['type_id'],
goal: $data['generation_goal'] ?? ''
);

$this->form->fill([
'game_id' => $data['game_id'],
'type_id' => $data['type_id'],
'name' => $result['name'] ?? '',
'card_text' => $result['card_text'] ?? ($result['lore_text'] ?? ''),
'card_data' => $result['card_data'] ?? [],
]);

Notification::make()
->success()
->title('AI Suggestion Applied')
->body('Review the generated fields and save when ready.')
->send();
} catch (AiSuggestionException $e) {
Notification::make()
->danger()
->title('AI Error')
->body($e->getMessage())
->send();
}
}),

Actions\Action::make('rerollAbility')
->label('Re-roll Ability')
->icon('heroicon-o-arrow-path')
->color('info')
->visible(fn () => auth()->user()?->supervisor === true)
->form([
Forms\Components\Select::make('tone')
->label('Tone')
->options([
'Aggressive' => 'Aggressive',
'Defensive' => 'Defensive',
'Support' => 'Support',
'Balanced' => 'Balanced',
])
->required()
->default('Balanced'),
])
->action(function (array $data) {
try {
$result = app(AbilityRerollService::class)->reroll(
cardId: $this->record->id,
tone: $data['tone']
);

$currentState = $this->form->getState();

$this->form->fill(array_merge($currentState, [
'card_text' => $result['card_text'] ?? $currentState['card_text'],
'card_data' => $result['card_data'] ?? $currentState['card_data'],
]));

if ($result['_truncated'] ?? false) {
Notification::make()
->warning()
->title('Ability Re-rolled (Truncated)')
->body('The generated text exceeded the max length and was truncated.')
->send();
} else {
Notification::make()
->success()
->title('Ability Re-rolled')
->body('Card ability updated. Save to persist the changes.')
->send();
}
} catch (AiSuggestionException $e) {
Notification::make()
->danger()
->title('AI Error')
->body($e->getMessage())
->send();
}
}),

Actions\DeleteAction::make(),
];
}
Expand Down
101 changes: 101 additions & 0 deletions app/Filament/Resources/DeckResource/Pages/EditDeck.php
Original file line number Diff line number Diff line change
@@ -1,9 +1,18 @@
<?php
/**
* Webtech-solutions 2025, All rights reserved.
*/

namespace App\Filament\Resources\DeckResource\Pages;

use App\Exceptions\AiSuggestionException;
use App\Filament\Resources\DeckResource;
use App\Models\Card;
use App\Models\Deck;
use App\Services\Ai\CardSwapService;
use App\Services\Ai\DeckAnalyticsService;
use Filament\Actions;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\EditRecord;

class EditDeck extends EditRecord
Expand All @@ -13,7 +22,99 @@ class EditDeck extends EditRecord
protected function getHeaderActions(): array
{
return [
Actions\Action::make('aiAnalytics')
->label('AI Analytics')
->icon('heroicon-o-chart-bar')
->color('success')
->visible(fn () => auth()->user()?->supervisor === true)
->slideOver()
->modalHeading('Deck Synergy Analysis')
->modalSubmitAction(false)
->modalCancelActionLabel('Close')
->modalContent(function () {
try {
$report = app(DeckAnalyticsService::class)->analyze($this->record->id);
return view('filament.modals.deck-analytics', ['report' => $report]);
} catch (AiSuggestionException $e) {
Notification::make()
->danger()
->title('AI Error')
->body($e->getMessage())
->send();
return view('filament.modals.deck-analytics-error', ['error' => $e->getMessage()]);
}
}),

Actions\Action::make('aiSwapSuggest')
->label('AI Card Swap')
->icon('heroicon-o-arrows-right-left')
->color('warning')
->visible(fn () => auth()->user()?->supervisor === true)
->slideOver()
->modalHeading('AI Card Swap Suggestions')
->modalSubmitAction(false)
->modalCancelActionLabel('Close')
->modalContent(function () {
try {
$suggestions = app(CardSwapService::class)->suggest($this->record->id);
return view('filament.modals.deck-swap', [
'suggestions' => $suggestions,
'deckId' => $this->record->id,
]);
} catch (AiSuggestionException $e) {
Notification::make()
->danger()
->title('AI Error')
->body($e->getMessage())
->send();
return view('filament.modals.deck-analytics-error', ['error' => $e->getMessage()]);
}
}),

Actions\DeleteAction::make(),
];
}

/**
* Livewire listener for applying a card swap from the modal view.
*/
public function applyCardSwap(int $removeId, int $replaceWithId): void
{
$deck = $this->record;
$deckData = $deck->deck_data ?? [];

// Remove old card
$deckData = array_values(array_filter($deckData, fn($item) => (int)($item['card_id'] ?? 0) !== $removeId));

// Add new card with quantity 1 if not present
$found = false;
foreach ($deckData as &$item) {
if ((int)($item['card_id'] ?? 0) === $replaceWithId) {
$item['quantity'] = ($item['quantity'] ?? 1) + 1;
$found = true;
break;
}
}
unset($item);

if (!$found) {
$deckData[] = ['card_id' => $replaceWithId, 'quantity' => 1];
}

$deck->deck_data = $deckData;
$deck->save();

$removedCard = Card::find($removeId);
$addedCard = Card::find($replaceWithId);

$this->form->fill(array_merge($this->form->getState(), [
'deck_data' => $deckData,
]));

Notification::make()
->success()
->title('Card Swapped')
->body("Replaced \"{$removedCard?->name}\" with \"{$addedCard?->name}\".")
->send();
}
}
Loading
Loading