1158 lines
35 KiB
Markdown
1158 lines
35 KiB
Markdown
# Nerve Stat + Gym Training Implementation Plan
|
|||
|
|
|
||
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
|
|
||
|
|
**Goal:** Add a nerve resource that caps daily crime volume and a gym page where players spend energy to train the four combat stats.
|
||
|
|
|
||
|
|
**Architecture:** Nerve mirrors the existing energy pattern — columns on `characters`, a `NerveService`, and two scheduled commands (trickle regen + midnight refill). Gym uses a new `GymController`, `TrainingService`, `GymSession` model, and a `gym_sessions` table with lazy daily-reset logic.
|
||
|
|
|
||
|
|
**Tech Stack:** Laravel 11, Blade + Tailwind CSS (dark crime theme), Alpine.js, Eloquent ORM, artisan scheduler.
|
||
|
|
|
||
|
|
## Global Constraints
|
||
|
|
|
||
|
|
- PHP 8.2+, Laravel 11.x
|
||
|
|
- All views must use the dark crime theme: `bg-syndicate-*`, `text-crime-*`, `text-gold-*` classes
|
||
|
|
- All new Blade views extend `layouts.game` and use `@section('game-content')`
|
||
|
|
- Follow existing constructor-injection pattern in controllers (no `app()` or facades in constructors)
|
||
|
|
- `$fillable` must be kept up to date on every model touched
|
||
|
|
- No `$timestamps = false` on new models — use default timestamps
|
||
|
|
- Artisan commands go in `app/Console/Commands/Game/` with signature prefix `game:`
|
||
|
|
- Scheduler registration goes in `routes/console.php`
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Task 1: Database migrations
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `database/migrations/2026_06_28_000001_add_nerve_to_characters_table.php`
|
||
|
|
- Create: `database/migrations/2026_06_28_000002_add_nerve_cost_to_crimes_table.php`
|
||
|
|
- Create: `database/migrations/2026_06_28_000003_add_nerve_cost_to_crime_attempts_table.php`
|
||
|
|
- Create: `database/migrations/2026_06_28_000004_create_gym_sessions_table.php`
|
||
|
|
|
||
|
|
**Interfaces:**
|
||
|
|
- Produces: `characters.nerve` (int, default 100), `characters.max_nerve` (int, default 100), `characters.nerve_regen_at` (timestamp nullable), `crimes.nerve_cost` (int, default 1), `crime_attempts.nerve_cost` (int, default 1), `gym_sessions` table
|
||
|
|
|
||
|
|
- [ ] **Step 1: Create the four migration files**
|
||
|
|
|
||
|
|
`database/migrations/2026_06_28_000001_add_nerve_to_characters_table.php`:
|
||
|
|
```php
|
||
|
|
<?php
|
||
|
|
|
||
|
|
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::table('characters', function (Blueprint $table) {
|
||
|
|
$table->integer('nerve')->default(100)->after('max_energy');
|
||
|
|
$table->integer('max_nerve')->default(100)->after('nerve');
|
||
|
|
$table->timestamp('nerve_regen_at')->nullable()->after('max_nerve');
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
public function down(): void
|
||
|
|
{
|
||
|
|
Schema::table('characters', function (Blueprint $table) {
|
||
|
|
$table->dropColumn(['nerve', 'max_nerve', 'nerve_regen_at']);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
};
|
||
|
|
```
|
||
|
|
|
||
|
|
`database/migrations/2026_06_28_000002_add_nerve_cost_to_crimes_table.php`:
|
||
|
|
```php
|
||
|
|
<?php
|
||
|
|
|
||
|
|
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::table('crimes', function (Blueprint $table) {
|
||
|
|
$table->integer('nerve_cost')->default(1)->after('energy_cost');
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
public function down(): void
|
||
|
|
{
|
||
|
|
Schema::table('crimes', function (Blueprint $table) {
|
||
|
|
$table->dropColumn('nerve_cost');
|
||
|
|
});
|
||
|
|
}
|
||
|
|
};
|
||
|
|
```
|
||
|
|
|
||
|
|
`database/migrations/2026_06_28_000003_add_nerve_cost_to_crime_attempts_table.php`:
|
||
|
|
```php
|
||
|
|
<?php
|
||
|
|
|
||
|
|
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::table('crime_attempts', function (Blueprint $table) {
|
||
|
|
$table->integer('nerve_cost')->default(1)->after('experience_gained');
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
public function down(): void
|
||
|
|
{
|
||
|
|
Schema::table('crime_attempts', function (Blueprint $table) {
|
||
|
|
$table->dropColumn('nerve_cost');
|
||
|
|
});
|
||
|
|
}
|
||
|
|
};
|
||
|
|
```
|
||
|
|
|
||
|
|
`database/migrations/2026_06_28_000004_create_gym_sessions_table.php`:
|
||
|
|
```php
|
||
|
|
<?php
|
||
|
|
|
||
|
|
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('gym_sessions', function (Blueprint $table) {
|
||
|
|
$table->id();
|
||
|
|
$table->foreignId('character_id')->constrained()->cascadeOnDelete();
|
||
|
|
$table->enum('stat', ['strength', 'defense', 'speed', 'dexterity']);
|
||
|
|
$table->integer('sessions_today')->default(0);
|
||
|
|
$table->date('reset_date');
|
||
|
|
$table->timestamps();
|
||
|
|
|
||
|
|
$table->unique(['character_id', 'stat']);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
public function down(): void
|
||
|
|
{
|
||
|
|
Schema::dropIfExists('gym_sessions');
|
||
|
|
}
|
||
|
|
};
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Run migrations**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
php artisan migrate
|
||
|
|
```
|
||
|
|
|
||
|
|
Expected output: four migration lines saying "Migrating:" then "Migrated:" with no errors.
|
||
|
|
|
||
|
|
- [ ] **Step 3: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add database/migrations/2026_06_28_000001_add_nerve_to_characters_table.php \
|
||
|
|
database/migrations/2026_06_28_000002_add_nerve_cost_to_crimes_table.php \
|
||
|
|
database/migrations/2026_06_28_000003_add_nerve_cost_to_crime_attempts_table.php \
|
||
|
|
database/migrations/2026_06_28_000004_create_gym_sessions_table.php
|
||
|
|
git commit -m "feat: add nerve and gym_sessions migrations"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Task 2: Config + Character model nerve methods
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Modify: `config/game.php`
|
||
|
|
- Modify: `app/Models/Character.php`
|
||
|
|
- Modify: `app/Models/CrimeAttempt.php`
|
||
|
|
- Modify: `app/Models/Crime.php`
|
||
|
|
|
||
|
|
**Interfaces:**
|
||
|
|
- Consumes: `characters.nerve`, `characters.max_nerve`, `characters.nerve_regen_at` (from Task 1)
|
||
|
|
- Produces:
|
||
|
|
- `config('game.nerve.start_amount')` → int
|
||
|
|
- `config('game.nerve.max')` → int
|
||
|
|
- `config('game.nerve.regen_rate')` → int
|
||
|
|
- `config('game.nerve.regen_interval')` → int
|
||
|
|
- `config('game.nerve.midnight_refill')` → bool
|
||
|
|
- `config('game.gym.energy_cost')` → int
|
||
|
|
- `config('game.gym.daily_cap')` → int
|
||
|
|
- `config('game.gym.stat_gain')` → int
|
||
|
|
- `config('game.energy.action_costs.gym')` → int
|
||
|
|
- `Character::hasNerve(int $amount): bool`
|
||
|
|
- `Character::consumeNerve(int $amount): bool`
|
||
|
|
- `Character::addNerve(int $amount): void`
|
||
|
|
- `Character::getNervePercentageAttribute(): float`
|
||
|
|
- `Character::levelUp()` increments `max_nerve` by 2
|
||
|
|
|
||
|
|
- [ ] **Step 1: Add nerve + gym blocks to config/game.php**
|
||
|
|
|
||
|
|
In `config/game.php`, add `'gym' => 20` to the `energy.action_costs` array:
|
||
|
|
|
||
|
|
```php
|
||
|
|
'action_costs' => [
|
||
|
|
'crime' => 10,
|
||
|
|
'attack' => 15,
|
||
|
|
'travel' => 5,
|
||
|
|
'train' => 20,
|
||
|
|
'gym' => 20,
|
||
|
|
],
|
||
|
|
```
|
||
|
|
|
||
|
|
Then add two new top-level blocks after the `'health'` block:
|
||
|
|
|
||
|
|
```php
|
||
|
|
// Nerve system configuration
|
||
|
|
'nerve' => [
|
||
|
|
'start_amount' => 100,
|
||
|
|
'max' => 100,
|
||
|
|
'regen_rate' => 1, // nerve per tick
|
||
|
|
'regen_interval' => 300, // seconds (5 minutes)
|
||
|
|
'midnight_refill' => true,
|
||
|
|
],
|
||
|
|
|
||
|
|
// Gym training configuration
|
||
|
|
'gym' => [
|
||
|
|
'energy_cost' => 20,
|
||
|
|
'daily_cap' => 5, // sessions per stat per day
|
||
|
|
'stat_gain' => 1, // stat points per session
|
||
|
|
],
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Add nerve fields and methods to Character model**
|
||
|
|
|
||
|
|
In `app/Models/Character.php`, add to `$fillable` array (after `'max_energy'`):
|
||
|
|
```php
|
||
|
|
'nerve',
|
||
|
|
'max_nerve',
|
||
|
|
'nerve_regen_at',
|
||
|
|
```
|
||
|
|
|
||
|
|
Add to `$casts` array:
|
||
|
|
```php
|
||
|
|
'nerve_regen_at' => 'datetime',
|
||
|
|
```
|
||
|
|
|
||
|
|
Add to the `levelUp()` method, after `$this->max_energy += 5;`:
|
||
|
|
```php
|
||
|
|
$this->max_nerve += 2;
|
||
|
|
```
|
||
|
|
|
||
|
|
Add these four methods after the `addEnergy()` method:
|
||
|
|
|
||
|
|
```php
|
||
|
|
public function hasNerve(int $amount): bool
|
||
|
|
{
|
||
|
|
return $this->nerve >= $amount;
|
||
|
|
}
|
||
|
|
|
||
|
|
public function consumeNerve(int $amount): bool
|
||
|
|
{
|
||
|
|
if (!$this->hasNerve($amount)) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
$this->nerve -= $amount;
|
||
|
|
$this->save();
|
||
|
|
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
public function addNerve(int $amount): void
|
||
|
|
{
|
||
|
|
$this->nerve = min($this->max_nerve, $this->nerve + $amount);
|
||
|
|
$this->save();
|
||
|
|
}
|
||
|
|
|
||
|
|
public function getNervePercentageAttribute(): float
|
||
|
|
{
|
||
|
|
if ($this->max_nerve <= 0) {
|
||
|
|
return 0;
|
||
|
|
}
|
||
|
|
return min(100, ($this->nerve / $this->max_nerve) * 100);
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 3: Add nerve_cost to CrimeAttempt model**
|
||
|
|
|
||
|
|
In `app/Models/CrimeAttempt.php`, add `'nerve_cost'` to `$fillable`:
|
||
|
|
```php
|
||
|
|
protected $fillable = [
|
||
|
|
'character_id',
|
||
|
|
'crime_id',
|
||
|
|
'success',
|
||
|
|
'reward',
|
||
|
|
'experience_gained',
|
||
|
|
'nerve_cost',
|
||
|
|
'attempted_at',
|
||
|
|
];
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 4: Add nerve_cost to Crime model**
|
||
|
|
|
||
|
|
In `app/Models/Crime.php`, add `'nerve_cost'` to `$fillable`:
|
||
|
|
```php
|
||
|
|
protected $fillable = [
|
||
|
|
'name',
|
||
|
|
'slug',
|
||
|
|
'description',
|
||
|
|
'difficulty',
|
||
|
|
'success_rate',
|
||
|
|
'reward_min',
|
||
|
|
'reward_max',
|
||
|
|
'experience',
|
||
|
|
'energy_cost',
|
||
|
|
'nerve_cost',
|
||
|
|
'cooldown',
|
||
|
|
'requires_level',
|
||
|
|
'location_id',
|
||
|
|
];
|
||
|
|
```
|
||
|
|
|
||
|
|
Also add `@property int $nerve_cost` to the docblock at the top of `Crime.php`:
|
||
|
|
```php
|
||
|
|
* @property int $nerve_cost
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 5: Verify with tinker**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
php artisan tinker
|
||
|
|
```
|
||
|
|
|
||
|
|
Then run:
|
||
|
|
```php
|
||
|
|
$c = App\Models\Character::first();
|
||
|
|
$c->nerve; // should return 100
|
||
|
|
$c->max_nerve; // should return 100
|
||
|
|
$c->hasNerve(1); // should return true
|
||
|
|
$c->consumeNerve(1); // should return true
|
||
|
|
$c->nerve; // should return 99
|
||
|
|
$c->addNerve(10);
|
||
|
|
$c->nerve; // should return 100 (capped at max)
|
||
|
|
$c->nerve_percentage; // should return 100.0
|
||
|
|
exit;
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 6: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add config/game.php app/Models/Character.php app/Models/CrimeAttempt.php app/Models/Crime.php
|
||
|
|
git commit -m "feat: add nerve config and Character nerve methods"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Task 3: NerveService + nerve regen commands
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `app/Services/Game/NerveService.php`
|
||
|
|
- Create: `app/Console/Commands/Game/ProcessNerveRegen.php`
|
||
|
|
- Create: `app/Console/Commands/Game/NerveMidnightRefill.php`
|
||
|
|
- Modify: `routes/console.php`
|
||
|
|
|
||
|
|
**Interfaces:**
|
||
|
|
- Consumes: `Character::addNerve()`, `Character::hasNerve()`, `Character::consumeNerve()`, `config('game.nerve.*')` (from Task 2)
|
||
|
|
- Produces:
|
||
|
|
- `NerveService::consume(Character $character, int $amount): bool`
|
||
|
|
- `NerveService::regenerate(Character $character): void`
|
||
|
|
- `NerveService::refillAll(): int` (returns count of characters updated)
|
||
|
|
- Artisan command `game:nerve-regen`
|
||
|
|
- Artisan command `game:nerve-midnight-refill`
|
||
|
|
- Scheduler registrations for both commands
|
||
|
|
|
||
|
|
- [ ] **Step 1: Create NerveService**
|
||
|
|
|
||
|
|
`app/Services/Game/NerveService.php`:
|
||
|
|
```php
|
||
|
|
<?php
|
||
|
|
|
||
|
|
namespace App\Services\Game;
|
||
|
|
|
||
|
|
use App\Models\Character;
|
||
|
|
|
||
|
|
class NerveService
|
||
|
|
{
|
||
|
|
public function consume(Character $character, int $amount): bool
|
||
|
|
{
|
||
|
|
if (!$character->canAct()) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
return $character->consumeNerve($amount);
|
||
|
|
}
|
||
|
|
|
||
|
|
public function regenerate(Character $character): void
|
||
|
|
{
|
||
|
|
$regenRate = config('game.nerve.regen_rate', 1);
|
||
|
|
|
||
|
|
if ($character->max_nerve <= 0) {
|
||
|
|
$character->max_nerve = config('game.nerve.max', 100);
|
||
|
|
}
|
||
|
|
|
||
|
|
$character->addNerve($regenRate);
|
||
|
|
}
|
||
|
|
|
||
|
|
public function refillAll(): int
|
||
|
|
{
|
||
|
|
return Character::where('nerve', '<', \DB::raw('max_nerve'))
|
||
|
|
->where('status', 'active')
|
||
|
|
->update(['nerve' => \DB::raw('max_nerve')]);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Create ProcessNerveRegen command**
|
||
|
|
|
||
|
|
`app/Console/Commands/Game/ProcessNerveRegen.php`:
|
||
|
|
```php
|
||
|
|
<?php
|
||
|
|
|
||
|
|
namespace App\Console\Commands\Game;
|
||
|
|
|
||
|
|
use App\Models\Character;
|
||
|
|
use App\Services\Game\NerveService;
|
||
|
|
use Illuminate\Console\Command;
|
||
|
|
|
||
|
|
class ProcessNerveRegen extends Command
|
||
|
|
{
|
||
|
|
protected $signature = 'game:nerve-regen';
|
||
|
|
protected $description = 'Regenerate nerve for all active characters';
|
||
|
|
|
||
|
|
public function handle(NerveService $nerveService): void
|
||
|
|
{
|
||
|
|
$this->info('Starting nerve regeneration...');
|
||
|
|
|
||
|
|
$characters = Character::where('nerve', '<', \DB::raw('max_nerve'))
|
||
|
|
->where('status', 'active')
|
||
|
|
->get();
|
||
|
|
|
||
|
|
$count = $characters->count();
|
||
|
|
$this->info("Regenerating nerve for {$count} characters...");
|
||
|
|
|
||
|
|
foreach ($characters as $character) {
|
||
|
|
$nerveService->regenerate($character);
|
||
|
|
}
|
||
|
|
|
||
|
|
$this->info('Nerve regeneration completed!');
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 3: Create NerveMidnightRefill command**
|
||
|
|
|
||
|
|
`app/Console/Commands/Game/NerveMidnightRefill.php`:
|
||
|
|
```php
|
||
|
|
<?php
|
||
|
|
|
||
|
|
namespace App\Console\Commands\Game;
|
||
|
|
|
||
|
|
use App\Services\Game\NerveService;
|
||
|
|
use Illuminate\Console\Command;
|
||
|
|
|
||
|
|
class NerveMidnightRefill extends Command
|
||
|
|
{
|
||
|
|
protected $signature = 'game:nerve-midnight-refill';
|
||
|
|
protected $description = 'Refill nerve to max for all active characters at midnight';
|
||
|
|
|
||
|
|
public function handle(NerveService $nerveService): void
|
||
|
|
{
|
||
|
|
$this->info('Running midnight nerve refill...');
|
||
|
|
|
||
|
|
$count = $nerveService->refillAll();
|
||
|
|
|
||
|
|
$this->info("Nerve refilled for {$count} characters.");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 4: Register commands in scheduler**
|
||
|
|
|
||
|
|
In `routes/console.php`, add after the existing `Artisan::command('inspire', ...)` block:
|
||
|
|
|
||
|
|
```php
|
||
|
|
use Illuminate\Support\Facades\Schedule;
|
||
|
|
|
||
|
|
Schedule::command('game:nerve-regen')->everyFiveMinutes();
|
||
|
|
Schedule::command('game:nerve-midnight-refill')->dailyAt('00:00');
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 5: Smoke-test both commands**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
php artisan game:nerve-regen
|
||
|
|
```
|
||
|
|
Expected output:
|
||
|
|
```
|
||
|
|
Starting nerve regeneration...
|
||
|
|
Regenerating nerve for 0 characters...
|
||
|
|
Nerve regeneration completed!
|
||
|
|
```
|
||
|
|
(0 because all characters are already at max nerve after migration.)
|
||
|
|
|
||
|
|
```bash
|
||
|
|
php artisan game:nerve-midnight-refill
|
||
|
|
```
|
||
|
|
Expected output:
|
||
|
|
```
|
||
|
|
Running midnight nerve refill...
|
||
|
|
Nerve refilled for 0 characters.
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 6: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add app/Services/Game/NerveService.php \
|
||
|
|
app/Console/Commands/Game/ProcessNerveRegen.php \
|
||
|
|
app/Console/Commands/Game/NerveMidnightRefill.php \
|
||
|
|
routes/console.php
|
||
|
|
git commit -m "feat: add NerveService and nerve regen scheduled commands"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Task 4: Wire nerve into crime attempts
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Modify: `app/Services/Game/CrimeService.php`
|
||
|
|
- Modify: `app/Models/Crime.php` (`canBeAttemptedBy`)
|
||
|
|
|
||
|
|
**Interfaces:**
|
||
|
|
- Consumes: `NerveService::consume()` (Task 3), `Character::hasNerve()` (Task 2), `Crime::nerve_cost` (Task 2)
|
||
|
|
- Produces: crimes check nerve before proceeding; `nerve_cost` recorded on `CrimeAttempt`
|
||
|
|
|
||
|
|
- [ ] **Step 1: Add nerve check to Crime::canBeAttemptedBy**
|
||
|
|
|
||
|
|
In `app/Models/Crime.php`, update `canBeAttemptedBy()` to also check nerve:
|
||
|
|
|
||
|
|
```php
|
||
|
|
public function canBeAttemptedBy(Character $character): bool
|
||
|
|
{
|
||
|
|
if ($this->requires_level && $character->level < $this->requires_level) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
if ($character->energy < $this->energy_cost) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
if ($character->nerve < $this->nerve_cost) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
if ($this->location_id && $character->location_id !== $this->location_id) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Update CrimeService to consume nerve and record nerve_cost**
|
||
|
|
|
||
|
|
In `app/Services/Game/CrimeService.php`:
|
||
|
|
|
||
|
|
Add `use App\Services\Game\NerveService;` to the imports.
|
||
|
|
|
||
|
|
Add `protected NerveService $nerveService;` as a class property.
|
||
|
|
|
||
|
|
Update the constructor to instantiate it:
|
||
|
|
```php
|
||
|
|
public function __construct()
|
||
|
|
{
|
||
|
|
$this->energyService = new EnergyService();
|
||
|
|
$this->economyService = new EconomyService();
|
||
|
|
$this->nerveService = new NerveService();
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
In `attemptCrime()`, add nerve consumption after the existing energy consumption block:
|
||
|
|
```php
|
||
|
|
// Consume nerve
|
||
|
|
if (!$this->nerveService->consume($character, $crime->nerve_cost)) {
|
||
|
|
throw new \Exception('Not enough nerve to attempt this crime.');
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
Update the `CrimeAttempt::create()` call to include `nerve_cost`:
|
||
|
|
```php
|
||
|
|
$attempt = CrimeAttempt::create([
|
||
|
|
'character_id' => $character->id,
|
||
|
|
'crime_id' => $crime->id,
|
||
|
|
'success' => $isSuccess,
|
||
|
|
'reward' => $reward,
|
||
|
|
'experience_gained' => $experience,
|
||
|
|
'nerve_cost' => $crime->nerve_cost,
|
||
|
|
'attempted_at' => now(),
|
||
|
|
]);
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 3: Manual test — attempt a crime and confirm nerve decrements**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
php artisan tinker
|
||
|
|
```
|
||
|
|
```php
|
||
|
|
$character = App\Models\Character::first();
|
||
|
|
$character->nerve; // note the starting value (e.g. 100)
|
||
|
|
|
||
|
|
$crime = App\Models\Crime::first();
|
||
|
|
$service = new App\Services\Game\CrimeService();
|
||
|
|
$attempt = $service->attemptCrime($character, $crime);
|
||
|
|
$attempt->nerve_cost; // should be 1
|
||
|
|
|
||
|
|
$character->fresh()->nerve; // should be 99
|
||
|
|
exit;
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 4: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add app/Services/Game/CrimeService.php app/Models/Crime.php
|
||
|
|
git commit -m "feat: wire nerve check and deduction into crime attempts"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Task 5: GymSession model + TrainingService
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `app/Models/GymSession.php`
|
||
|
|
- Create: `app/Services/Game/TrainingService.php`
|
||
|
|
|
||
|
|
**Interfaces:**
|
||
|
|
- Consumes: `Character::consumeEnergy()`, `config('game.gym.*')` (Task 2)
|
||
|
|
- Produces:
|
||
|
|
- `TrainingService::train(Character $character, string $stat): array` — returns `['success' => bool, 'message' => string, 'stat_value' => int]`
|
||
|
|
- `TrainingService::getSessionsToday(Character $character, string $stat): int`
|
||
|
|
- `TrainingService::getSessionsRemaining(Character $character, string $stat): int`
|
||
|
|
- `GymSession` Eloquent model for `gym_sessions` table
|
||
|
|
|
||
|
|
- [ ] **Step 1: Create GymSession model**
|
||
|
|
|
||
|
|
`app/Models/GymSession.php`:
|
||
|
|
```php
|
||
|
|
<?php
|
||
|
|
|
||
|
|
namespace App\Models;
|
||
|
|
|
||
|
|
use Illuminate\Database\Eloquent\Model;
|
||
|
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @property int $id
|
||
|
|
* @property int $character_id
|
||
|
|
* @property string $stat
|
||
|
|
* @property int $sessions_today
|
||
|
|
* @property \Illuminate\Support\Carbon $reset_date
|
||
|
|
*/
|
||
|
|
class GymSession extends Model
|
||
|
|
{
|
||
|
|
protected $fillable = [
|
||
|
|
'character_id',
|
||
|
|
'stat',
|
||
|
|
'sessions_today',
|
||
|
|
'reset_date',
|
||
|
|
];
|
||
|
|
|
||
|
|
protected $casts = [
|
||
|
|
'reset_date' => 'date',
|
||
|
|
];
|
||
|
|
|
||
|
|
public function character(): BelongsTo
|
||
|
|
{
|
||
|
|
return $this->belongsTo(Character::class);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Create TrainingService**
|
||
|
|
|
||
|
|
`app/Services/Game/TrainingService.php`:
|
||
|
|
```php
|
||
|
|
<?php
|
||
|
|
|
||
|
|
namespace App\Services\Game;
|
||
|
|
|
||
|
|
use App\Models\Character;
|
||
|
|
use App\Models\GymSession;
|
||
|
|
|
||
|
|
class TrainingService
|
||
|
|
{
|
||
|
|
private const VALID_STATS = ['strength', 'defense', 'speed', 'dexterity'];
|
||
|
|
|
||
|
|
public function train(Character $character, string $stat): array
|
||
|
|
{
|
||
|
|
if (!in_array($stat, self::VALID_STATS, true)) {
|
||
|
|
return ['success' => false, 'message' => 'Invalid stat.', 'stat_value' => 0];
|
||
|
|
}
|
||
|
|
|
||
|
|
$energyCost = config('game.gym.energy_cost', 20);
|
||
|
|
$dailyCap = config('game.gym.daily_cap', 5);
|
||
|
|
$statGain = config('game.gym.stat_gain', 1);
|
||
|
|
|
||
|
|
if (!$character->hasEnergy($energyCost)) {
|
||
|
|
return [
|
||
|
|
'success' => false,
|
||
|
|
'message' => "Not enough energy ({$energyCost} required).",
|
||
|
|
'stat_value' => $character->$stat,
|
||
|
|
];
|
||
|
|
}
|
||
|
|
|
||
|
|
$sessionsToday = $this->getSessionsToday($character, $stat);
|
||
|
|
|
||
|
|
if ($sessionsToday >= $dailyCap) {
|
||
|
|
return [
|
||
|
|
'success' => false,
|
||
|
|
'message' => "Daily training cap reached for {$stat} ({$dailyCap} sessions).",
|
||
|
|
'stat_value' => $character->$stat,
|
||
|
|
];
|
||
|
|
}
|
||
|
|
|
||
|
|
// Deduct energy
|
||
|
|
$character->consumeEnergy($energyCost);
|
||
|
|
|
||
|
|
// Increment stat
|
||
|
|
$character->$stat += $statGain;
|
||
|
|
$character->save();
|
||
|
|
|
||
|
|
// Upsert gym_sessions row
|
||
|
|
$session = GymSession::firstOrNew([
|
||
|
|
'character_id' => $character->id,
|
||
|
|
'stat' => $stat,
|
||
|
|
]);
|
||
|
|
|
||
|
|
if (!$session->reset_date || $session->reset_date->lt(today())) {
|
||
|
|
$session->sessions_today = 0;
|
||
|
|
$session->reset_date = today();
|
||
|
|
}
|
||
|
|
|
||
|
|
$session->sessions_today += 1;
|
||
|
|
$session->save();
|
||
|
|
|
||
|
|
return [
|
||
|
|
'success' => true,
|
||
|
|
'message' => ucfirst($stat) . " increased to {$character->$stat}!",
|
||
|
|
'stat_value' => $character->$stat,
|
||
|
|
];
|
||
|
|
}
|
||
|
|
|
||
|
|
public function getSessionsToday(Character $character, string $stat): int
|
||
|
|
{
|
||
|
|
$session = GymSession::where('character_id', $character->id)
|
||
|
|
->where('stat', $stat)
|
||
|
|
->first();
|
||
|
|
|
||
|
|
if (!$session || $session->reset_date->lt(today())) {
|
||
|
|
return 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
return $session->sessions_today;
|
||
|
|
}
|
||
|
|
|
||
|
|
public function getSessionsRemaining(Character $character, string $stat): int
|
||
|
|
{
|
||
|
|
$cap = config('game.gym.daily_cap', 5);
|
||
|
|
return max(0, $cap - $this->getSessionsToday($character, $stat));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 3: Test TrainingService via tinker**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
php artisan tinker
|
||
|
|
```
|
||
|
|
```php
|
||
|
|
$character = App\Models\Character::first();
|
||
|
|
$character->strength; // note starting value
|
||
|
|
|
||
|
|
$service = new App\Services\Game\TrainingService();
|
||
|
|
|
||
|
|
// Train strength
|
||
|
|
$result = $service->train($character, 'strength');
|
||
|
|
$result['success']; // true
|
||
|
|
$result['stat_value']; // original + 1
|
||
|
|
|
||
|
|
$character->fresh()->strength; // should match stat_value
|
||
|
|
$character->fresh()->energy; // should be 20 less
|
||
|
|
|
||
|
|
// Check session count
|
||
|
|
$service->getSessionsToday($character->fresh(), 'strength'); // 1
|
||
|
|
$service->getSessionsRemaining($character->fresh(), 'strength'); // 4
|
||
|
|
|
||
|
|
// Train 4 more times to hit cap
|
||
|
|
$service->train($character->fresh(), 'strength');
|
||
|
|
$service->train($character->fresh(), 'strength');
|
||
|
|
$service->train($character->fresh(), 'strength');
|
||
|
|
$result = $service->train($character->fresh(), 'strength');
|
||
|
|
$result['success']; // true (5th session)
|
||
|
|
|
||
|
|
$result = $service->train($character->fresh(), 'strength');
|
||
|
|
$result['success']; // false — capped
|
||
|
|
$result['message']; // "Daily training cap reached..."
|
||
|
|
exit;
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 4: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add app/Models/GymSession.php app/Services/Game/TrainingService.php
|
||
|
|
git commit -m "feat: add GymSession model and TrainingService"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Task 6: GymController + routes
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `app/Http/Controllers/Game/GymController.php`
|
||
|
|
- Modify: `routes/game.php`
|
||
|
|
|
||
|
|
**Interfaces:**
|
||
|
|
- Consumes: `TrainingService::train()`, `TrainingService::getSessionsToday()`, `TrainingService::getSessionsRemaining()` (Task 5); `config('game.gym.*')` (Task 2)
|
||
|
|
- Produces:
|
||
|
|
- `GET /game/gym` → `game.gym.index` view with `$character`, `$stats`, `$energyCost`, `$dailyCap`
|
||
|
|
- `POST /game/gym/train` → redirects to `game.gym.index` with flash
|
||
|
|
|
||
|
|
- [ ] **Step 1: Create GymController**
|
||
|
|
|
||
|
|
`app/Http/Controllers/Game/GymController.php`:
|
||
|
|
```php
|
||
|
|
<?php
|
||
|
|
|
||
|
|
namespace App\Http\Controllers\Game;
|
||
|
|
|
||
|
|
use App\Http\Controllers\Controller;
|
||
|
|
use App\Services\Game\TrainingService;
|
||
|
|
use Illuminate\Http\RedirectResponse;
|
||
|
|
use Illuminate\Http\Request;
|
||
|
|
use Illuminate\View\View;
|
||
|
|
|
||
|
|
class GymController extends Controller
|
||
|
|
{
|
||
|
|
protected TrainingService $trainingService;
|
||
|
|
|
||
|
|
public function __construct()
|
||
|
|
{
|
||
|
|
$this->trainingService = new TrainingService();
|
||
|
|
}
|
||
|
|
|
||
|
|
public function index(Request $request): View
|
||
|
|
{
|
||
|
|
$character = $request->user()->character;
|
||
|
|
$dailyCap = config('game.gym.daily_cap', 5);
|
||
|
|
$energyCost = config('game.gym.energy_cost', 20);
|
||
|
|
|
||
|
|
$stats = [];
|
||
|
|
foreach (['strength', 'defense', 'speed', 'dexterity'] as $stat) {
|
||
|
|
$stats[$stat] = [
|
||
|
|
'value' => $character->$stat,
|
||
|
|
'sessions_today' => $this->trainingService->getSessionsToday($character, $stat),
|
||
|
|
'sessions_remaining'=> $this->trainingService->getSessionsRemaining($character, $stat),
|
||
|
|
];
|
||
|
|
}
|
||
|
|
|
||
|
|
return view('game.gym.index', compact('character', 'stats', 'energyCost', 'dailyCap'));
|
||
|
|
}
|
||
|
|
|
||
|
|
public function train(Request $request): RedirectResponse
|
||
|
|
{
|
||
|
|
$request->validate(['stat' => 'required|in:strength,defense,speed,dexterity']);
|
||
|
|
|
||
|
|
$character = $request->user()->character;
|
||
|
|
$result = $this->trainingService->train($character, $request->stat);
|
||
|
|
|
||
|
|
if ($result['success']) {
|
||
|
|
return redirect()->route('game.gym.index')->with('success', $result['message']);
|
||
|
|
}
|
||
|
|
|
||
|
|
return redirect()->route('game.gym.index')->with('error', $result['message']);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Add gym routes to routes/game.php**
|
||
|
|
|
||
|
|
In `routes/game.php`, add inside the `Route::middleware(['auth', 'verify.character'])` group, after the inventory routes block:
|
||
|
|
|
||
|
|
```php
|
||
|
|
// Gym routes
|
||
|
|
Route::prefix('game/gym')->name('game.gym.')->group(function () {
|
||
|
|
Route::get('/', [GymController::class, 'index'])->name('index');
|
||
|
|
Route::post('/train', [GymController::class, 'train'])->name('train')->middleware(['check.energy:gym']);
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
Also add the import at the top of the file:
|
||
|
|
```php
|
||
|
|
use App\Http\Controllers\Game\GymController;
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 3: Verify routes are registered**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
php artisan route:list --name=game.gym
|
||
|
|
```
|
||
|
|
|
||
|
|
Expected output showing two routes:
|
||
|
|
```
|
||
|
|
GET|HEAD game/gym game.gym.index GymController@index
|
||
|
|
POST game/gym/train game.gym.train GymController@train
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 4: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add app/Http/Controllers/Game/GymController.php routes/game.php
|
||
|
|
git commit -m "feat: add GymController and gym routes"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Task 7: Gym view (dark crime theme)
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `resources/views/game/gym/index.blade.php`
|
||
|
|
|
||
|
|
**Interfaces:**
|
||
|
|
- Consumes: `$character`, `$stats` (array keyed by stat name with `value`, `sessions_today`, `sessions_remaining`), `$energyCost`, `$dailyCap` (from Task 6)
|
||
|
|
- Produces: rendered gym page at `/game/gym`
|
||
|
|
|
||
|
|
- [ ] **Step 1: Create the gym view**
|
||
|
|
|
||
|
|
`resources/views/game/gym/index.blade.php`:
|
||
|
|
```blade
|
||
|
|
@extends('layouts.game')
|
||
|
|
|
||
|
|
@section('title', 'Gym')
|
||
|
|
|
||
|
|
@section('game-content')
|
||
|
|
<div class="max-w-3xl mx-auto">
|
||
|
|
|
||
|
|
{{-- Header --}}
|
||
|
|
<div class="mb-6">
|
||
|
|
<h1 class="font-display text-2xl font-bold text-gray-100 uppercase tracking-widest">Gym</h1>
|
||
|
|
<p class="text-gray-500 text-sm mt-1">
|
||
|
|
Train your stats. Each session costs {{ $energyCost }} energy. Max {{ $dailyCap }} sessions per stat per day.
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{{-- Energy warning --}}
|
||
|
|
@if($character->energy < $energyCost)
|
||
|
|
<div class="mb-4 px-4 py-3 bg-yellow-900/40 border border-yellow-700 text-yellow-400 rounded-lg text-sm">
|
||
|
|
You need at least {{ $energyCost }} energy to train. Current energy: {{ $character->energy }}/{{ $character->max_energy }}.
|
||
|
|
</div>
|
||
|
|
@endif
|
||
|
|
|
||
|
|
{{-- Stat cards --}}
|
||
|
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||
|
|
@foreach($stats as $stat => $data)
|
||
|
|
@php
|
||
|
|
$icons = ['strength' => '💪', 'defense' => '🛡️', 'speed' => '⚡', 'dexterity' => '🎯'];
|
||
|
|
$labels = ['strength' => 'Strength', 'defense' => 'Defense', 'speed' => 'Speed', 'dexterity' => 'Dexterity'];
|
||
|
|
$canTrain = $character->energy >= $energyCost && $data['sessions_remaining'] > 0;
|
||
|
|
@endphp
|
||
|
|
<div class="bg-syndicate-800 border border-syndicate-700 rounded-lg p-5">
|
||
|
|
<div class="flex items-center justify-between mb-4">
|
||
|
|
<div class="flex items-center space-x-2">
|
||
|
|
<span class="text-2xl">{{ $icons[$stat] }}</span>
|
||
|
|
<div>
|
||
|
|
<h2 class="font-display text-base font-semibold text-gray-100">{{ $labels[$stat] }}</h2>
|
||
|
|
<p class="text-xs text-gray-500">Current: <span class="text-crime-400 font-bold">{{ $data['value'] }}</span></p>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div class="text-right">
|
||
|
|
<p class="text-xs text-gray-500">Sessions today</p>
|
||
|
|
<p class="text-sm font-medium {{ $data['sessions_remaining'] > 0 ? 'text-gray-300' : 'text-red-400' }}">
|
||
|
|
{{ $data['sessions_today'] }} / {{ $dailyCap }}
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{{-- Progress bar for daily cap --}}
|
||
|
|
<div class="w-full bg-syndicate-700 rounded-full h-1.5 mb-4">
|
||
|
|
<div class="bg-crime-600 h-1.5 rounded-full transition-all"
|
||
|
|
style="width: {{ $dailyCap > 0 ? ($data['sessions_today'] / $dailyCap) * 100 : 0 }}%"></div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<form method="POST" action="{{ route('game.gym.train') }}">
|
||
|
|
@csrf
|
||
|
|
<input type="hidden" name="stat" value="{{ $stat }}">
|
||
|
|
<button type="submit"
|
||
|
|
@if(!$canTrain) disabled @endif
|
||
|
|
class="w-full py-2 px-4 rounded text-sm font-medium transition
|
||
|
|
{{ $canTrain
|
||
|
|
? 'bg-crime-600 hover:bg-crime-500 text-white'
|
||
|
|
: 'bg-syndicate-700 text-gray-600 cursor-not-allowed' }}">
|
||
|
|
@if($data['sessions_remaining'] <= 0)
|
||
|
|
Capped for today
|
||
|
|
@elseif($character->energy < $energyCost)
|
||
|
|
Not enough energy
|
||
|
|
@else
|
||
|
|
Train (+1) — {{ $energyCost }} energy
|
||
|
|
@endif
|
||
|
|
</button>
|
||
|
|
</form>
|
||
|
|
</div>
|
||
|
|
@endforeach
|
||
|
|
</div>
|
||
|
|
|
||
|
|
</div>
|
||
|
|
@endsection
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Load the gym page in a browser and verify it renders**
|
||
|
|
|
||
|
|
Start the dev server if not running:
|
||
|
|
```bash
|
||
|
|
php artisan serve
|
||
|
|
```
|
||
|
|
|
||
|
|
Visit `http://localhost:8000/game/gym`. Confirm:
|
||
|
|
- Four stat cards render (Strength, Defense, Speed, Dexterity)
|
||
|
|
- Each shows current stat value and 0/5 sessions today
|
||
|
|
- Train buttons are active (green) when energy ≥ 20
|
||
|
|
- Page uses dark crime theme consistent with other game pages
|
||
|
|
|
||
|
|
- [ ] **Step 3: Test train action in browser**
|
||
|
|
|
||
|
|
Click "Train (+1) — 20 energy" on Strength. Confirm:
|
||
|
|
- Page redirects back to `/game/gym`
|
||
|
|
- Success flash message appears: "Strength increased to X!"
|
||
|
|
- Strength card now shows updated value and 1/5 sessions
|
||
|
|
- Energy in the top bar decreased by 20
|
||
|
|
|
||
|
|
- [ ] **Step 4: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add resources/views/game/gym/index.blade.php
|
||
|
|
git commit -m "feat: add gym page view"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Task 8: Nerve bar in stats bar + nav link + dashboard
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Modify: `resources/views/game/partials/stats-bar.blade.php`
|
||
|
|
- Modify: `resources/views/layouts/navigation.blade.php`
|
||
|
|
- Modify: `resources/views/game/dashboard.blade.php`
|
||
|
|
|
||
|
|
**Interfaces:**
|
||
|
|
- Consumes: `$character->nerve`, `$character->max_nerve` (Task 2); `route('game.gym.index')` (Task 6)
|
||
|
|
- Produces: nerve displayed in top stats bar; Gym link in sidebar; nerve bar card on dashboard
|
||
|
|
|
||
|
|
- [ ] **Step 1: Add nerve to stats-bar partial**
|
||
|
|
|
||
|
|
In `resources/views/game/partials/stats-bar.blade.php`, add a nerve chip after the Energy chip (after the closing `</div>` of the Energy block):
|
||
|
|
|
||
|
|
```blade
|
||
|
|
{{-- Nerve --}}
|
||
|
|
<div class="flex items-center space-x-1 bg-syndicate-800 border border-syndicate-700 rounded px-2 py-1">
|
||
|
|
<span class="text-purple-400">🧠</span>
|
||
|
|
<span class="text-gray-300">{{ $character->nerve }}<span class="text-gray-600">/{{ $character->max_nerve }}</span></span>
|
||
|
|
</div>
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Add Gym link to sidebar navigation**
|
||
|
|
|
||
|
|
In `resources/views/layouts/navigation.blade.php`, add after the Inventory `<li>` block and before the Profile `<li>` block:
|
||
|
|
|
||
|
|
```blade
|
||
|
|
<li>
|
||
|
|
<a href="{{ route('game.gym.index') }}"
|
||
|
|
class="flex items-center space-x-3 px-3 py-2 rounded-lg text-sm transition
|
||
|
|
{{ request()->routeIs('game.gym.*') ? 'border-l-2 border-crime-500 bg-syndicate-800 text-gray-100 pl-[10px]' : 'text-gray-400 hover:bg-syndicate-800 hover:text-gray-100' }}">
|
||
|
|
<span class="text-base">💪</span>
|
||
|
|
<span>Gym</span>
|
||
|
|
</a>
|
||
|
|
</li>
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 3: Add nerve card to dashboard**
|
||
|
|
|
||
|
|
In `resources/views/game/dashboard.blade.php`, add a new card after the "Quick Stats" card. Insert it as the third item in the grid, pushing Finances to position 4 (the grid handles reflow automatically):
|
||
|
|
|
||
|
|
```blade
|
||
|
|
{{-- Nerve --}}
|
||
|
|
<div class="bg-syndicate-800 border border-syndicate-700 rounded-lg p-6">
|
||
|
|
<h3 class="font-display text-lg font-semibold text-gray-100 mb-4">Nerve</h3>
|
||
|
|
<div class="space-y-3">
|
||
|
|
<div class="flex justify-between text-sm">
|
||
|
|
<span class="text-gray-400">Available</span>
|
||
|
|
<span class="text-purple-400 font-medium">{{ $character->nerve }} / {{ $character->max_nerve }}</span>
|
||
|
|
</div>
|
||
|
|
<div class="w-full bg-syndicate-700 rounded-full h-2">
|
||
|
|
<div class="bg-purple-600 h-2 rounded-full transition-all"
|
||
|
|
style="width: {{ $character->nerve_percentage }}%"></div>
|
||
|
|
</div>
|
||
|
|
<p class="text-xs text-gray-600">
|
||
|
|
Nerve gates crime volume. Recharges over time and fully refills at midnight.
|
||
|
|
</p>
|
||
|
|
<a href="{{ route('game.gym.index') }}"
|
||
|
|
class="block w-full text-center bg-syndicate-700 hover:bg-syndicate-600 text-gray-300 py-2 px-4 rounded text-sm transition mt-2">
|
||
|
|
Go to Gym
|
||
|
|
</a>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 4: Verify in browser**
|
||
|
|
|
||
|
|
Visit `http://localhost:8000/game`. Confirm:
|
||
|
|
- Nerve chip (`🧠 100/100`) appears in the top stats bar between Energy and Cash
|
||
|
|
- Gym link (`💪 Gym`) appears in the sidebar and highlights when on `/game/gym`
|
||
|
|
- Dashboard shows a Nerve card with a purple progress bar and "Go to Gym" link
|
||
|
|
|
||
|
|
- [ ] **Step 5: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add resources/views/game/partials/stats-bar.blade.php \
|
||
|
|
resources/views/layouts/navigation.blade.php \
|
||
|
|
resources/views/game/dashboard.blade.php
|
||
|
|
git commit -m "feat: add nerve to stats bar, gym nav link, nerve dashboard card"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Self-Review
|
||
|
|
|
||
|
|
**Spec coverage check:**
|
||
|
|
|
||
|
|
| Spec requirement | Task |
|
||
|
|
|-----------------|------|
|
||
|
|
| nerve/max_nerve/nerve_regen_at columns on characters | Task 1 |
|
||
|
|
| nerve_cost on crimes table (default 1) | Task 1 |
|
||
|
|
| nerve_cost on crime_attempts table | Task 1 |
|
||
|
|
| gym_sessions table with unique(character_id, stat) | Task 1 |
|
||
|
|
| config/game.php nerve + gym blocks | Task 2 |
|
||
|
|
| config gym.energy_cost added to energy.action_costs | Task 2 |
|
||
|
|
| Character::hasNerve, consumeNerve, addNerve, nerve_percentage | Task 2 |
|
||
|
|
| CrimeAttempt $fillable includes nerve_cost | Task 2 |
|
||
|
|
| Crime $fillable includes nerve_cost | Task 2 |
|
||
|
|
| levelUp() increments max_nerve by 2 | Task 2 |
|
||
|
|
| NerveService::consume, regenerate, refillAll | Task 3 |
|
||
|
|
| game:nerve-regen artisan command | Task 3 |
|
||
|
|
| game:nerve-midnight-refill artisan command | Task 3 |
|
||
|
|
| Scheduler registrations | Task 3 |
|
||
|
|
| Crime::canBeAttemptedBy checks nerve | Task 4 |
|
||
|
|
| CrimeService consumes nerve + records nerve_cost | Task 4 |
|
||
|
|
| GymSession model | Task 5 |
|
||
|
|
| TrainingService::train with lazy daily reset | Task 5 |
|
||
|
|
| TrainingService::getSessionsToday + getSessionsRemaining | Task 5 |
|
||
|
|
| GymController::index + train | Task 6 |
|
||
|
|
| GET /game/gym + POST /game/gym/train routes | Task 6 |
|
||
|
|
| check.energy:gym middleware on train route | Task 6 |
|
||
|
|
| Gym view (dark theme, 4 stat cards, disabled states) | Task 7 |
|
||
|
|
| Nerve chip in stats bar | Task 8 |
|
||
|
|
| Gym link in sidebar nav | Task 8 |
|
||
|
|
| Nerve card on dashboard | Task 8 |
|
||
|
|
|
||
|
|
All spec requirements covered. No placeholders found. Types and method names are consistent across tasks.
|