feat(city): add east shore beach leisure activity

Characters can now relax on the beach sand to recover energy and
health. The server clamps position to the shoreline, exposes beach
geometry in the city payload, and the client renders animated waves,
sand, stalls, chairs, and ambient NPCs.

Includes Beach support class, new beach action endpoint, localization
strings, and feature tests.
This commit is contained in:
2026-10-31 20:35:22 -03:00
parent 4b71a9d0ff
commit b8c31e7be4
12 changed files with 827 additions and 5 deletions
+117
View File
@@ -0,0 +1,117 @@
<?php
namespace Tests\Feature;
use App\Models\Character;
use App\Models\User;
use Database\Seeders\CityWorldSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* East-beach leisure action and its geometry in the world payload.
*/
class BeachLeisureTest 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);
}
/**
* The world payload carries the beach geometry derived from the grid.
*/
public function test_city_data_includes_beach_geometry(): void
{
$payload = $this->getJson('/city/data')->assertOk()->json();
$this->assertArrayHasKey('beach', $payload);
// Layout max X (200 for these bounds) + approach → sand starts at 204.
$this->assertEquals(204.0, $payload['beach']['sand_from']);
$this->assertEquals(264.0, $payload['beach']['sand_to']);
$this->assertEquals(354.0, $payload['beach']['sea_to']);
$this->assertEquals(-160.0, $payload['beach']['z_min']);
$this->assertEquals(160.0, $payload['beach']['z_max']);
}
/**
* Relaxing on the sand recovers one point of energy and health.
*/
public function test_relax_on_sand_recovers_energy_and_health(): void
{
$this->character->forceFill(['energy' => 40, 'health' => 50])->save();
$this->postJson('/city/actions/beach', ['pos_x' => 210, 'pos_z' => -40])
->assertOk()
->assertJsonPath('message', __('city.beach_ok'));
$this->character->refresh();
$this->assertSame(41, (int) $this->character->energy);
$this->assertSame(51, (int) $this->character->health);
}
/**
* The action is rejected away from the beach.
*/
public function test_relax_requires_being_on_the_beach(): void
{
$this->postJson('/city/actions/beach', ['pos_x' => 0, 'pos_z' => 0])
->assertStatus(422)
->assertJsonPath('message', __('city.beach_need'));
$this->postJson('/city/actions/beach', ['pos_x' => 300, 'pos_z' => 0])
->assertStatus(422)
->assertJsonPath('message', __('city.beach_need'));
}
/**
* Fully restored characters are told they are already at max.
*/
public function test_relax_at_max_stats_reports_full(): void
{
$this->character->forceFill(['energy' => 100, 'health' => 100])->save();
$this->postJson('/city/actions/beach', ['pos_x' => 220, 'pos_z' => 30])
->assertOk()
->assertJsonPath('message', __('city.beach_full'));
}
/**
* Sleeping characters cannot relax.
*/
public function test_sleeping_character_cannot_relax(): void
{
$this->character->forceFill(['state' => Character::STATE_SLEEPING])->save();
$this->postJson('/city/actions/beach', ['pos_x' => 210, 'pos_z' => 0])
->assertStatus(422)
->assertJsonPath('message', __('city.exercise_need_awake'));
}
/**
* Position sync persists up to the shoreline (and no further).
*/
public function test_position_persists_onto_the_beach(): void
{
$onSand = $this->postJson('/city/actions/position', ['pos_x' => 250, 'pos_z' => 0])
->assertOk();
$this->assertEquals(250, $onSand->json('pos_x'));
$pastShore = $this->postJson('/city/actions/position', ['pos_x' => 400, 'pos_z' => 0])
->assertOk();
$this->assertEquals(266, $pastShore->json('pos_x'));
}
}
+4 -2
View File
@@ -8,6 +8,7 @@ use App\Models\InventoryItem;
use App\Models\Job;
use App\Models\Transaction;
use App\Models\User;
use App\Support\Beach;
use Database\Seeders\CityWorldSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
@@ -56,7 +57,8 @@ class CityActionsTest extends TestCase
}
/**
* Position updates clamp inside city bounds and persist.
* Position updates clamp to the beach shoreline (past the road grid)
* and persist; other axes clamp to the city bounds.
*/
public function test_position_persists_and_clamps(): void
{
@@ -65,7 +67,7 @@ class CityActionsTest extends TestCase
$this->character->refresh();
$city = $this->character->city;
$this->assertSame((float) $city->bounds_max_x, $this->character->pos_x);
$this->assertSame(Beach::sandTo($city) + 2, $this->character->pos_x);
$this->assertSame((float) $city->bounds_min_z, $this->character->pos_z);
}