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:
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user