feat(city): add plaza exercise system with health tracking

- Adds CityPlazas support class for deterministic public plaza generation with outdoor gym equipment
- Implements exercise and stop-exercise actions with proximity validation to equipment
- Introduces health (0-100) and exercise_count attributes to Character model
- Reduces hospital stay duration from two game days to one game hour
- Adds 3D gym equipment rendering (bench, bars, bike, treadmill) in city viewer
- Includes exercise state machine handling in world ticker with energy drain
- Adds comprehensive PlazaExerciseTest and updates HospitalAdmissionTest
- Provides localized UI strings for health HUD and exercise prompts
This commit is contained in:
2026-10-17 22:10:50 -03:00
parent 79ce2a1825
commit 090b530481
18 changed files with 848 additions and 21 deletions
+6 -6
View File
@@ -11,7 +11,7 @@ use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* Traffic accident → hospital admission for two game days.
* Traffic accident → hospital admission for one game hour.
*/
class HospitalAdmissionTest extends TestCase
{
@@ -34,9 +34,9 @@ class HospitalAdmissionTest extends TestCase
}
/**
* A vehicle collision admits the character for two game days at the hospital.
* A vehicle collision admits the character for one game hour at the hospital.
*/
public function test_accident_admits_character_for_two_game_days(): void
public function test_accident_admits_character_for_one_game_hour(): void
{
$response = $this->postJson('/city/actions/accident');
$response->assertOk()
@@ -51,7 +51,7 @@ class HospitalAdmissionTest extends TestCase
$this->character->hospitalized_until->getTimestamp(),
5,
);
$this->assertSame(Character::HOSPITAL_STAY_SECONDS, 480, 'Two game days = 2 × 240s');
$this->assertSame(Character::HOSPITAL_STAY_SECONDS, 10, 'One game hour = 240s / 24');
$hospital = Building::where('type', Building::TYPE_HOSPITAL)->firstOrFail();
$this->assertSame((float) $hospital->pos_x, (float) $this->character->pos_x);
@@ -88,9 +88,9 @@ class HospitalAdmissionTest extends TestCase
}
/**
* After the two-day stay the ticker releases the character.
* After the one-hour stay the ticker releases the character.
*/
public function test_ticker_releases_after_two_game_days(): void
public function test_ticker_releases_after_one_game_hour(): void
{
$this->character->admitToHospital();
// Move the stay into the past to simulate elapsed time.
+139
View File
@@ -0,0 +1,139 @@
<?php
namespace Tests\Feature;
use App\Models\Character;
use App\Models\User;
use App\Support\CityPlazas;
use Database\Seeders\CityWorldSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* Public plaza gym equipment and health gains from exercise.
*/
class PlazaExerciseTest extends TestCase
{
use RefreshDatabase;
protected User $user;
protected Character $character;
/**
* Boot world fixtures.
*/
protected function setUp(): void
{
parent::setUp();
$this->seed(CityWorldSeeder::class);
$this->user = User::factory()->create(['status' => User::STATUS_ACTIVE, 'is_active' => true]);
$this->character = Character::ensureForUser($this->user);
$this->actingAs($this->user);
}
/**
* City payload includes plazas with gym equipment.
*/
public function test_city_data_includes_plazas(): void
{
$this->getJson('/city/data')
->assertOk()
->assertJsonStructure([
'plazas' => [
['id', 'position', 'size', 'equipment' => [['id', 'kind', 'position']]],
],
]);
$this->assertNotEmpty(CityPlazas::forCity($this->character->city));
}
/**
* Exercising near equipment raises health and increments the counter.
*/
public function test_exercise_gains_health_near_equipment(): void
{
$eq = CityPlazas::forCity($this->character->city)[0]['equipment'][0];
$this->character->forceFill([
'pos_x' => $eq['position'][0],
'pos_z' => $eq['position'][1],
'health' => 80,
'energy' => 90,
'state' => Character::STATE_AWAKE,
])->save();
$this->postJson('/city/actions/exercise', [
'pos_x' => $eq['position'][0],
'pos_z' => $eq['position'][1],
])->assertOk()
->assertJsonPath('character.health', 82)
->assertJsonPath('character.state', Character::STATE_EXERCISING);
$this->character->refresh();
$this->assertSame(82, (int) $this->character->health);
$this->assertSame(1, (int) $this->character->exercise_count);
$this->assertSame(88, (int) $this->character->energy);
}
/**
* Far from equipment the action is rejected.
*/
public function test_exercise_requires_nearby_equipment(): void
{
$eq = CityPlazas::forCity($this->character->city)[0]['equipment'][0];
$this->character->forceFill([
'pos_x' => $eq['position'][0] + 50,
'pos_z' => $eq['position'][1] + 50,
'health' => 50,
])->save();
$this->postJson('/city/actions/exercise', [
'pos_x' => $eq['position'][0] + 50,
'pos_z' => $eq['position'][1] + 50,
])->assertStatus(422);
}
/**
* Max health refuses further gains but keeps the character healthy.
*/
public function test_exercise_stops_at_max_health(): void
{
$eq = CityPlazas::forCity($this->character->city)[0]['equipment'][0];
$this->character->forceFill([
'pos_x' => $eq['position'][0],
'pos_z' => $eq['position'][1],
'health' => 100,
])->save();
$this->postJson('/city/actions/exercise', [
'pos_x' => $eq['position'][0],
'pos_z' => $eq['position'][1],
])->assertOk()
->assertJsonPath('character.health', 100);
$this->assertSame(0, (int) $this->character->fresh()->exercise_count);
}
/**
* stop-exercise returns the character to awake.
*/
public function test_stop_exercise_returns_awake(): void
{
$eq = CityPlazas::forCity($this->character->city)[0]['equipment'][0];
$this->character->forceFill([
'pos_x' => $eq['position'][0],
'pos_z' => $eq['position'][1],
'health' => 70,
'state' => Character::STATE_AWAKE,
])->save();
$this->postJson('/city/actions/exercise', [
'pos_x' => $eq['position'][0],
'pos_z' => $eq['position'][1],
])->assertOk();
$this->postJson('/city/actions/stop-exercise')
->assertOk()
->assertJsonPath('character.state', Character::STATE_AWAKE);
}
}