Files
2026-06-28 15:34:01 +03:00

35 KiB

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

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

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

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

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
php artisan migrate

Expected output: four migration lines saying "Migrating:" then "Migrated:" with no errors.

  • Step 3: Commit
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:

'action_costs' => [
    'crime' => 10,
    'attack' => 15,
    'travel' => 5,
    'train' => 20,
    'gym' => 20,
],

Then add two new top-level blocks after the 'health' block:

// 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'):

'nerve',
'max_nerve',
'nerve_regen_at',

Add to $casts array:

'nerve_regen_at' => 'datetime',

Add to the levelUp() method, after $this->max_energy += 5;:

$this->max_nerve += 2;

Add these four methods after the addEnergy() method:

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:

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:

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:

 * @property int $nerve_cost
  • Step 5: Verify with tinker
php artisan tinker

Then run:

$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
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

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

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

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:

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
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.)

php artisan game:nerve-midnight-refill

Expected output:

Running midnight nerve refill...
Nerve refilled for 0 characters.
  • Step 6: Commit
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:

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:

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:

// 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:

$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
php artisan tinker
$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
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

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

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
php artisan tinker
$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
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

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:

// 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:

use App\Http\Controllers\Game\GymController;
  • Step 3: Verify routes are registered
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
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:

@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:

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

git add resources/views/game/gym/index.blade.php
git commit -m "feat: add gym page view"

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):

{{-- 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:

<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):

{{-- 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

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.