docs: add escape from jail implementation plan
This commit is contained in:
@@ -0,0 +1,476 @@
|
|||||||
|
# Escape from Jail 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 Bust Out mechanic to the jail page — spend 1000 cash, roll against a dexterity+speed formula, succeed to escape or fail to extend your sentence by 1.5×.
|
||||||
|
|
||||||
|
**Architecture:** A new `JailService` holds all bust-out logic (mirrors the existing `HealthService::revive()` pattern). A new `JailController` handles the HTTP action. The existing jail view gains a Bust Out card with a CSRF form. No new database migrations.
|
||||||
|
|
||||||
|
**Tech Stack:** Laravel 11, PHP 8.2, Blade templating, Eloquent ORM, Laravel Sail (Docker). All `artisan` commands must be run via `./vendor/bin/sail artisan`.
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- Project runs in **Laravel Sail** — all `php artisan` commands must use `./vendor/bin/sail artisan`
|
||||||
|
- Dark crime theme: Tailwind classes `bg-syndicate-*`, `text-crime-*`, `bg-crime-*`, `text-yellow-*` — match the existing jail page colour palette (yellow for jail, not purple/green)
|
||||||
|
- Services are instantiated with `new` in controllers — no DI container injection (see existing `CharacterController`, `GymController`)
|
||||||
|
- No PHPUnit in this project — manual verification via `./vendor/bin/sail artisan tinker` and browser testing only
|
||||||
|
- Flash messages use `session()->flash('success', ...)` / `session()->flash('error', ...)`; displayed via existing layout partials
|
||||||
|
- CSRF required on all POST forms (`@csrf`)
|
||||||
|
- Routes file: `routes/game.php`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Map
|
||||||
|
|
||||||
|
| Action | File |
|
||||||
|
|--------|------|
|
||||||
|
| Modify | `config/game.php` — add two keys to `jail` block |
|
||||||
|
| Create | `app/Services/Game/JailService.php` |
|
||||||
|
| Create | `app/Http/Controllers/Game/JailController.php` |
|
||||||
|
| Modify | `routes/game.php` — add bust-out route and pass new vars to jail view |
|
||||||
|
| Modify | `resources/views/game/jail.blade.php` — add Bust Out card |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Config + JailService
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `config/game.php` (lines 81–86)
|
||||||
|
- Create: `app/Services/Game/JailService.php`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces:
|
||||||
|
- `JailService::bustOut(Character $character): array{success: bool, message: string}`
|
||||||
|
- `JailService::sendToJail(Character $character, int $seconds): void`
|
||||||
|
- `JailService::releaseFromJail(Character $character): void`
|
||||||
|
- `JailService::calculateBustOutChance(Character $character): float` (returns 0.0–1.0)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add two config keys to the `jail` block in `config/game.php`**
|
||||||
|
|
||||||
|
Open `config/game.php`. The existing `jail` block is at lines 81–86:
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Jail system configuration
|
||||||
|
'jail' => [
|
||||||
|
'sentence_length' => 3600,
|
||||||
|
'breakout_chance' => 0.05,
|
||||||
|
'breakout_cost' => 1000,
|
||||||
|
'sentence_multiplier' => 1.5,
|
||||||
|
'bust_out_stat_multiplier' => 0.003, // per combined dex+speed point
|
||||||
|
'bust_out_max_chance' => 0.80, // cap at 80%
|
||||||
|
],
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace the existing block (4 keys) with the 6-key version above.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify config is readable**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./vendor/bin/sail artisan tinker
|
||||||
|
```
|
||||||
|
|
||||||
|
In the tinker prompt:
|
||||||
|
```php
|
||||||
|
config('game.jail.bust_out_stat_multiplier'); // expect 0.003
|
||||||
|
config('game.jail.bust_out_max_chance'); // expect 0.8
|
||||||
|
exit
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Create `app/Services/Game/JailService.php`**
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Game;
|
||||||
|
|
||||||
|
use App\Models\Character;
|
||||||
|
|
||||||
|
class JailService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Calculate bust-out success chance for a character (0.0 to 1.0).
|
||||||
|
*/
|
||||||
|
public function calculateBustOutChance(Character $character): float
|
||||||
|
{
|
||||||
|
$base = config('game.jail.breakout_chance', 0.05);
|
||||||
|
$multiplier = config('game.jail.bust_out_stat_multiplier', 0.003);
|
||||||
|
$cap = config('game.jail.bust_out_max_chance', 0.80);
|
||||||
|
|
||||||
|
return min($cap, $base + ($character->dexterity + $character->speed) * $multiplier);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempt to bust out of jail.
|
||||||
|
*
|
||||||
|
* Deducts breakout_cost cash regardless of outcome.
|
||||||
|
* On success: releases character immediately.
|
||||||
|
* On failure: multiplies remaining sentence by sentence_multiplier.
|
||||||
|
*
|
||||||
|
* @return array{success: bool, message: string}
|
||||||
|
*/
|
||||||
|
public function bustOut(Character $character): array
|
||||||
|
{
|
||||||
|
$cost = config('game.jail.breakout_cost', 1000);
|
||||||
|
|
||||||
|
if ($character->money < $cost) {
|
||||||
|
return [
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'You need $' . number_format($cost) . ' to attempt a bust-out.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$character->money -= $cost;
|
||||||
|
$successRate = $this->calculateBustOutChance($character);
|
||||||
|
$roll = random_int(0, 9999) / 10000;
|
||||||
|
|
||||||
|
if ($roll <= $successRate) {
|
||||||
|
$this->releaseFromJail($character);
|
||||||
|
$character->save();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'success' => true,
|
||||||
|
'message' => "You escaped! You're free.",
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Failure: extend sentence
|
||||||
|
$multiplier = config('game.jail.sentence_multiplier', 1.5);
|
||||||
|
$remaining = max(0, $character->in_jail_until->diffInSeconds(now()));
|
||||||
|
$extended = (int) ceil($remaining * $multiplier);
|
||||||
|
|
||||||
|
$character->in_jail_until = now()->addSeconds($extended);
|
||||||
|
$character->save();
|
||||||
|
|
||||||
|
$newReleaseTime = $character->in_jail_until->diffForHumans();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'success' => false,
|
||||||
|
'message' => "Bust failed — your sentence was extended to {$newReleaseTime}.",
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a character to jail for the given number of seconds.
|
||||||
|
*/
|
||||||
|
public function sendToJail(Character $character, int $seconds): void
|
||||||
|
{
|
||||||
|
$character->status = 'in_jail';
|
||||||
|
$character->in_jail_until = now()->addSeconds($seconds);
|
||||||
|
$character->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Release a character from jail immediately.
|
||||||
|
*/
|
||||||
|
public function releaseFromJail(Character $character): void
|
||||||
|
{
|
||||||
|
$character->status = 'active';
|
||||||
|
$character->in_jail_until = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Smoke-test the service in tinker**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./vendor/bin/sail artisan tinker
|
||||||
|
```
|
||||||
|
|
||||||
|
```php
|
||||||
|
use App\Services\Game\JailService;
|
||||||
|
use App\Models\Character;
|
||||||
|
|
||||||
|
$svc = new JailService();
|
||||||
|
|
||||||
|
// Fake a character with known stats
|
||||||
|
$char = new Character(['dexterity' => 5, 'speed' => 5]);
|
||||||
|
$chance = $svc->calculateBustOutChance($char);
|
||||||
|
// expect: 0.05 + (5+5)*0.003 = 0.08
|
||||||
|
echo $chance; // 0.08
|
||||||
|
|
||||||
|
$char2 = new Character(['dexterity' => 50, 'speed' => 50]);
|
||||||
|
echo $svc->calculateBustOutChance($char2); // 0.35
|
||||||
|
|
||||||
|
$char3 = new Character(['dexterity' => 100, 'speed' => 100]);
|
||||||
|
echo $svc->calculateBustOutChance($char3); // 0.8 (capped)
|
||||||
|
exit
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add config/game.php app/Services/Game/JailService.php
|
||||||
|
git commit -m "feat: add JailService and bust-out config keys"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: JailController + Route
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `app/Http/Controllers/Game/JailController.php`
|
||||||
|
- Modify: `routes/game.php`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes:
|
||||||
|
- `JailService::bustOut(Character): array{success: bool, message: string}` (Task 1)
|
||||||
|
- `JailService::calculateBustOutChance(Character): float` (Task 1)
|
||||||
|
- Produces:
|
||||||
|
- Route `game.jail.bust-out` (POST `/game/jail/bust-out`)
|
||||||
|
- Updated jail view route passes `$bustOutChance` and `$bustOutCost` to the view
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create `app/Http/Controllers/Game/JailController.php`**
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Game;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Services\Game\JailService;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class JailController extends Controller
|
||||||
|
{
|
||||||
|
protected JailService $jailService;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->jailService = new JailService();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempt to bust out of jail.
|
||||||
|
*/
|
||||||
|
public function bustOut(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$character = $request->user()->character;
|
||||||
|
|
||||||
|
if (!$character || !$character->isInJail()) {
|
||||||
|
return redirect()->route('game.dashboard');
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $this->jailService->bustOut($character);
|
||||||
|
|
||||||
|
if ($result['success']) {
|
||||||
|
return redirect()->route('game.dashboard')
|
||||||
|
->with('success', $result['message']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()->route('game.jail')
|
||||||
|
->with('error', $result['message']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add the bust-out route and update the jail GET route in `routes/game.php`**
|
||||||
|
|
||||||
|
The existing jail route group starts at line 81:
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Jail route (for when character is in jail)
|
||||||
|
Route::middleware(['auth'])->group(function () {
|
||||||
|
Route::get('/game/jail', function () {
|
||||||
|
$character = auth()->user()->character;
|
||||||
|
if (!$character || !$character->isInJail()) {
|
||||||
|
return redirect()->route('game.dashboard');
|
||||||
|
}
|
||||||
|
$releaseTime = $character->in_jail_until ? $character->in_jail_until->diffForHumans() : 'unknown';
|
||||||
|
$bustOutChance = (int) round((new \App\Services\Game\JailService())->calculateBustOutChance($character) * 100);
|
||||||
|
$bustOutCost = config('game.jail.breakout_cost', 1000);
|
||||||
|
return view('game.jail', compact('character', 'releaseTime', 'bustOutChance', 'bustOutCost'));
|
||||||
|
})->name('game.jail');
|
||||||
|
|
||||||
|
Route::post('/game/jail/bust-out', [\App\Http\Controllers\Game\JailController::class, 'bustOut'])
|
||||||
|
->name('game.jail.bust-out');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace the existing jail group (lines 81–91 of `routes/game.php`) with the block above.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify routes are registered**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./vendor/bin/sail artisan route:list --name=game.jail
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected output includes two rows:
|
||||||
|
```
|
||||||
|
GET /game/jail game.jail
|
||||||
|
POST /game/jail/bust-out game.jail.bust-out
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add app/Http/Controllers/Game/JailController.php routes/game.php
|
||||||
|
git commit -m "feat: add JailController and bust-out route"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Jail View — Bust Out Card
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `resources/views/game/jail.blade.php`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes (variables passed from the route closure, Task 2):
|
||||||
|
- `$character` — Character model (has `->money`)
|
||||||
|
- `$releaseTime` — string, e.g. "in 45 minutes"
|
||||||
|
- `$bustOutChance` — int (percentage, e.g. 8 for 8%)
|
||||||
|
- `$bustOutCost` — int (e.g. 1000)
|
||||||
|
- `$bustOutCost` formatted with `number_format()` in the view
|
||||||
|
|
||||||
|
- [ ] **Step 1: Replace `resources/views/game/jail.blade.php` with the updated version**
|
||||||
|
|
||||||
|
```blade
|
||||||
|
@extends('layouts.game')
|
||||||
|
|
||||||
|
@section('title', 'Jail')
|
||||||
|
|
||||||
|
@section('game-content')
|
||||||
|
<div class="max-w-2xl mx-auto space-y-4">
|
||||||
|
|
||||||
|
{{-- Flash messages --}}
|
||||||
|
@if(session('success'))
|
||||||
|
<div class="bg-green-900/30 border border-green-700 text-green-300 rounded-lg px-4 py-3 text-sm">
|
||||||
|
{{ session('success') }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
@if(session('error'))
|
||||||
|
<div class="bg-red-900/30 border border-red-700 text-red-300 rounded-lg px-4 py-3 text-sm">
|
||||||
|
{{ session('error') }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div class="bg-syndicate-800 border border-syndicate-700 rounded-lg">
|
||||||
|
<div class="p-8 text-center">
|
||||||
|
<div class="mb-6">
|
||||||
|
<span class="text-6xl">🚔</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 class="font-display text-2xl font-bold text-gray-100 mb-4">You're in Jail</h1>
|
||||||
|
<p class="text-gray-400 mb-6 text-sm">Your character has been arrested and is currently serving time.</p>
|
||||||
|
|
||||||
|
{{-- Status card --}}
|
||||||
|
<div class="bg-yellow-900/20 border border-yellow-800 rounded-lg p-6 mb-6 text-left">
|
||||||
|
<h3 class="font-display text-base font-semibold text-yellow-300 mb-3">Your Status</h3>
|
||||||
|
<div class="space-y-2 text-sm">
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-gray-400">Status</span>
|
||||||
|
<span class="text-yellow-400 font-medium">In Jail</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-gray-400">Release Time</span>
|
||||||
|
<span class="text-gray-200 font-medium">{{ $releaseTime }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-gray-400">Cash on Hand</span>
|
||||||
|
<span class="text-gray-200 font-medium">${{ number_format($character->money) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Bust Out card --}}
|
||||||
|
<div class="bg-syndicate-700 border border-syndicate-600 rounded-lg p-6 mb-6 text-left">
|
||||||
|
<h3 class="font-display text-base font-semibold text-gray-100 mb-1">Bust Out</h3>
|
||||||
|
<p class="text-gray-400 text-xs mb-4">Spend cash for a chance to escape early. Failure extends your sentence.</p>
|
||||||
|
|
||||||
|
<div class="space-y-2 text-sm mb-4">
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-gray-400">Success Chance</span>
|
||||||
|
<span class="text-yellow-400 font-medium">{{ $bustOutChance }}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-gray-400">Cost</span>
|
||||||
|
<span class="text-gray-200 font-medium">${{ number_format($bustOutCost) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-gray-400">On Failure</span>
|
||||||
|
<span class="text-red-400 font-medium">Sentence × 1.5</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if($character->money >= $bustOutCost)
|
||||||
|
<form action="{{ route('game.jail.bust-out') }}" method="POST">
|
||||||
|
@csrf
|
||||||
|
<button type="submit"
|
||||||
|
class="w-full flex items-center justify-center px-6 py-3 bg-yellow-700 hover:bg-yellow-600 text-white font-medium rounded-lg transition">
|
||||||
|
<span class="mr-2">🔓</span>
|
||||||
|
Bust Out ({{ $bustOutChance }}% chance)
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
@else
|
||||||
|
<button type="button" disabled
|
||||||
|
title="You need ${{ number_format($bustOutCost) }} to attempt a bust-out"
|
||||||
|
class="w-full flex items-center justify-center px-6 py-3 bg-syndicate-600 text-gray-500 font-medium rounded-lg cursor-not-allowed">
|
||||||
|
<span class="mr-2">🔒</span>
|
||||||
|
Not Enough Cash
|
||||||
|
</button>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<p class="text-sm text-gray-600">Or wait for your sentence to complete automatically.</p>
|
||||||
|
|
||||||
|
<a href="{{ route('game.dashboard') }}"
|
||||||
|
class="inline-flex items-center text-gray-500 hover:text-gray-300 transition text-sm">
|
||||||
|
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
Back to Dashboard
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endsection
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify the jail page renders**
|
||||||
|
|
||||||
|
In a browser (or via `curl`), navigate to `/game/jail` while logged in with a jailed character. Confirm:
|
||||||
|
- Status card shows release time and cash on hand
|
||||||
|
- Bust Out card shows correct % and cost
|
||||||
|
- If character has ≥ $1,000: button is active (yellow), form submits to `game.jail.bust-out`
|
||||||
|
- If character has < $1,000: button is disabled, grey, shows tooltip
|
||||||
|
|
||||||
|
To temporarily put a character in jail for testing:
|
||||||
|
```bash
|
||||||
|
./vendor/bin/sail artisan tinker
|
||||||
|
```
|
||||||
|
```php
|
||||||
|
use App\Models\Character;
|
||||||
|
$c = Character::first();
|
||||||
|
$c->status = 'in_jail';
|
||||||
|
$c->in_jail_until = now()->addHour();
|
||||||
|
$c->save();
|
||||||
|
exit
|
||||||
|
```
|
||||||
|
|
||||||
|
Then visit `/game/jail`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Test the full bust-out flow**
|
||||||
|
|
||||||
|
With the jailed character, click "Bust Out":
|
||||||
|
- **Success path:** You are redirected to `/game` with a green "You escaped! You're free." flash.
|
||||||
|
- **Failure path:** You stay on `/game/jail` with a red "Bust failed — your sentence was extended to..." flash and the release time has increased.
|
||||||
|
|
||||||
|
Verify cash was deducted in both cases:
|
||||||
|
```bash
|
||||||
|
./vendor/bin/sail artisan tinker
|
||||||
|
```
|
||||||
|
```php
|
||||||
|
use App\Models\Character;
|
||||||
|
Character::first()->money; // should be 1000 less than before
|
||||||
|
exit
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add resources/views/game/jail.blade.php
|
||||||
|
git commit -m "feat: add bust-out card to jail page"
|
||||||
|
```
|
||||||
Reference in New Issue
Block a user