Files
thecrimesynticate/docs/superpowers/plans/2026-06-28-nerve-gym.md
T

1158 lines
35 KiB
Markdown
Raw Normal View History

# 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) &mdash; {{ $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.