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.gameand use@section('game-content') - Follow existing constructor-injection pattern in controllers (no
app()or facades in constructors) $fillablemust be kept up to date on every model touched- No
$timestamps = falseon new models — use default timestamps - Artisan commands go in
app/Console/Commands/Game/with signature prefixgame: - 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_sessionstable -
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')→ intconfig('game.nerve.max')→ intconfig('game.nerve.regen_rate')→ intconfig('game.nerve.regen_interval')→ intconfig('game.nerve.midnight_refill')→ boolconfig('game.gym.energy_cost')→ intconfig('game.gym.daily_cap')→ intconfig('game.gym.stat_gain')→ intconfig('game.energy.action_costs.gym')→ intCharacter::hasNerve(int $amount): boolCharacter::consumeNerve(int $amount): boolCharacter::addNerve(int $amount): voidCharacter::getNervePercentageAttribute(): floatCharacter::levelUp()incrementsmax_nerveby 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): boolNerveService::regenerate(Character $character): voidNerveService::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_costrecorded onCrimeAttempt -
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): intTrainingService::getSessionsRemaining(Character $character, string $stat): intGymSessionEloquent model forgym_sessionstable
-
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.indexview with$character,$stats,$energyCost,$dailyCapPOST /game/gym/train→ redirects togame.gym.indexwith 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 withvalue,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) — {{ $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"
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):
{{-- 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.