Implements deterministic model resolution (column → env map → pool → default) and adds AI identity fields to characters. Migration backfills existing rows with deterministic defaults. Controller uses resolved model and identity block in system prompts for contextual conversations.
321 lines
9.1 KiB
PHP
Executable File
321 lines
9.1 KiB
PHP
Executable File
<?php
|
|
|
|
namespace App\Models;
|
|
|
|
use Database\Factories\CharacterFactory;
|
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
use Illuminate\Database\Eloquent\Model;
|
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
|
|
|
/**
|
|
* In-world avatar of a platform user (or NPC child without a user).
|
|
*
|
|
* Stores position, survival needs, employment and social state. The world
|
|
* ticker mutates needs and job progress continuously while nobody is online.
|
|
*/
|
|
#[Fillable([
|
|
'user_id', 'city_id', 'name', 'ai_model', 'ai_age', 'ai_gender',
|
|
'ai_personality', 'ai_bio', 'skin', 'hair', 'shirt',
|
|
'pos_x', 'pos_y', 'pos_z', 'money', 'energy', 'hunger', 'thirst', 'health',
|
|
'state', 'job_id', 'house_id', 'education', 'exercise_count', 'is_online', 'last_tick_at',
|
|
'hospitalized_until', 'face', 'beard', 'accessory', 'ride_kind', 'ride_until',
|
|
])]
|
|
class Character extends Model
|
|
{
|
|
/** @use HasFactory<CharacterFactory, $this> */
|
|
use HasFactory;
|
|
|
|
/**
|
|
* Default personality pool for characters created without one.
|
|
*
|
|
* @var list<string>
|
|
*/
|
|
public const AI_PERSONALITIES = [
|
|
'Extrovertido e curioso, adora puxar conversa com quem passa.',
|
|
'Tranquilo e observador, fala pouco mas escuta tudo com atenção.',
|
|
'Bem-humorado e espirituoso, brinca com todo mundo por perto.',
|
|
'Sério e direto, vai ao ponto sem rodeios.',
|
|
'Gentil e prestativo, sempre oferece ajuda antes de perguntarem.',
|
|
'Aventureiro e barulhento, vive contando histórias da cidade.',
|
|
];
|
|
|
|
public const STATE_AWAKE = 'awake';
|
|
|
|
public const STATE_SLEEPING = 'sleeping';
|
|
|
|
public const STATE_WORKING = 'working';
|
|
|
|
public const STATE_STUDYING = 'studying';
|
|
|
|
/**
|
|
* Character is exercising on public plaza gym equipment.
|
|
*
|
|
* @var string
|
|
*/
|
|
public const STATE_EXERCISING = 'exercising';
|
|
|
|
/**
|
|
* Character admitted to the hospital after a traffic accident.
|
|
*
|
|
* @var string
|
|
*/
|
|
public const STATE_HOSPITAL = 'hospital';
|
|
|
|
/**
|
|
* Hospital stay length in seconds (one game hour).
|
|
*
|
|
* The day/night cycle is 240 real seconds per 24 game hours, so one
|
|
* game hour equals 10 real seconds.
|
|
*
|
|
* @var int
|
|
*/
|
|
public const HOSPITAL_STAY_SECONDS = 10;
|
|
|
|
/**
|
|
* Default hospital bed coordinates used when no hospital building exists.
|
|
* Kept mid-block (away from road lanes) so vehicles never hit the bed.
|
|
*
|
|
* @var array{0: float, 1: float}
|
|
*/
|
|
public const HOSPITAL_POS = [60.0, -51.0];
|
|
|
|
/**
|
|
* Owning user account (null for NPC children).
|
|
*
|
|
* @return BelongsTo<User, $this>
|
|
*/
|
|
public function user(): BelongsTo
|
|
{
|
|
return $this->belongsTo(User::class);
|
|
}
|
|
|
|
/**
|
|
* City where this character lives.
|
|
*
|
|
* @return BelongsTo<City, $this>
|
|
*/
|
|
public function city(): BelongsTo
|
|
{
|
|
return $this->belongsTo(City::class);
|
|
}
|
|
|
|
/**
|
|
* Current job position, when employed.
|
|
*
|
|
* @return BelongsTo<Job, $this>
|
|
*/
|
|
public function job(): BelongsTo
|
|
{
|
|
return $this->belongsTo(Job::class);
|
|
}
|
|
|
|
/**
|
|
* Complete house owned by this character, when any.
|
|
*
|
|
* @return BelongsTo<Building, $this>
|
|
*/
|
|
public function house(): BelongsTo
|
|
{
|
|
return $this->belongsTo(Building::class, 'house_id');
|
|
}
|
|
|
|
/**
|
|
* Buildings constructed by this character.
|
|
*
|
|
* @return HasMany<Building, $this>
|
|
*/
|
|
public function buildings(): HasMany
|
|
{
|
|
return $this->hasMany(Building::class, 'owner_character_id');
|
|
}
|
|
|
|
/**
|
|
* Marketplace listings published by this character.
|
|
*
|
|
* @return HasMany<MarketListing, $this>
|
|
*/
|
|
public function listings(): HasMany
|
|
{
|
|
return $this->hasMany(MarketListing::class, 'seller_character_id');
|
|
}
|
|
|
|
/**
|
|
* Missions this character is tracking.
|
|
*
|
|
* @return BelongsToMany<Mission, $this>
|
|
*/
|
|
public function missions(): BelongsToMany
|
|
{
|
|
return $this->belongsToMany(Mission::class, 'character_missions')
|
|
->withPivot(['progress', 'is_completed', 'completed_at'])
|
|
->withTimestamps();
|
|
}
|
|
|
|
/**
|
|
* Inventory stacks owned by this character.
|
|
*
|
|
* @return HasMany<InventoryItem, $this>
|
|
*/
|
|
public function inventory(): HasMany
|
|
{
|
|
return $this->hasMany(InventoryItem::class);
|
|
}
|
|
|
|
/**
|
|
* Character of a linked user, when this account has one.
|
|
*/
|
|
public function forUser(int $userId): bool
|
|
{
|
|
return (int) $this->user_id === $userId;
|
|
}
|
|
|
|
/**
|
|
* Get or create the character for an approved user in the given city.
|
|
*/
|
|
public static function ensureForUser(User $user, ?City $city = null): self
|
|
{
|
|
$existing = self::query()->where('user_id', $user->id)->first();
|
|
if ($existing) {
|
|
return $existing;
|
|
}
|
|
|
|
$city ??= City::active();
|
|
abort_if($city === null, 500, 'No active city available.');
|
|
|
|
$firstName = trim(explode(' ', $user->name)[0] ?? $user->email);
|
|
|
|
$character = self::create([
|
|
'user_id' => $user->id,
|
|
'city_id' => $city->id,
|
|
'name' => $firstName,
|
|
'ai_age' => random_int(18, 68),
|
|
'ai_personality' => self::AI_PERSONALITIES[array_rand(self::AI_PERSONALITIES)],
|
|
'skin' => random_int(1, 4),
|
|
'hair' => random_int(1, 4),
|
|
'shirt' => random_int(1, 5),
|
|
'pos_x' => 0,
|
|
'pos_y' => 0,
|
|
'pos_z' => 25,
|
|
'money' => 100,
|
|
'energy' => 100,
|
|
'hunger' => 100,
|
|
'thirst' => 100,
|
|
'health' => 100,
|
|
'state' => self::STATE_AWAKE,
|
|
'education' => 0,
|
|
'exercise_count' => 0,
|
|
'is_online' => false,
|
|
'face' => random_int(1, 3),
|
|
'beard' => random_int(0, 2),
|
|
'accessory' => random_int(0, 2),
|
|
]);
|
|
|
|
// Starter kit so new citizens can eat, drink and build immediately.
|
|
foreach ([
|
|
InventoryItem::ITEM_FOOD => 3,
|
|
InventoryItem::ITEM_WATER => 3,
|
|
InventoryItem::ITEM_MATERIALS => 60,
|
|
] as $item => $qty) {
|
|
InventoryItem::create([
|
|
'character_id' => $character->id,
|
|
'item' => $item,
|
|
'qty' => $qty,
|
|
]);
|
|
}
|
|
|
|
return $character;
|
|
}
|
|
|
|
/**
|
|
* Get the attributes that should be cast.
|
|
*
|
|
* @return array<string, string>
|
|
*/
|
|
protected function casts(): array
|
|
{
|
|
return [
|
|
'ai_age' => 'integer',
|
|
'pos_x' => 'float',
|
|
'pos_y' => 'float',
|
|
'pos_z' => 'float',
|
|
'money' => 'float',
|
|
'energy' => 'integer',
|
|
'hunger' => 'integer',
|
|
'thirst' => 'integer',
|
|
'health' => 'integer',
|
|
'education' => 'integer',
|
|
'exercise_count' => 'integer',
|
|
'is_online' => 'boolean',
|
|
'last_tick_at' => 'datetime',
|
|
'hospitalized_until' => 'datetime',
|
|
'face' => 'integer',
|
|
'beard' => 'integer',
|
|
'accessory' => 'integer',
|
|
'ride_until' => 'datetime',
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Determine whether the character is currently admitted to the hospital.
|
|
*/
|
|
public function isHospitalized(): bool
|
|
{
|
|
return $this->state === self::STATE_HOSPITAL
|
|
&& $this->hospitalized_until !== null
|
|
&& $this->hospitalized_until->isFuture();
|
|
}
|
|
|
|
/**
|
|
* Admit the character to the hospital for one game hour after an accident.
|
|
*
|
|
* Teleports them to the city hospital bed and returns the bed position.
|
|
*
|
|
* @return array{0: float, 1: float}
|
|
*/
|
|
public function admitToHospital(): array
|
|
{
|
|
$hospital = Building::query()
|
|
->where('city_id', $this->city_id)
|
|
->where('type', Building::TYPE_HOSPITAL)
|
|
->where('is_complete', true)
|
|
->first();
|
|
|
|
$x = $hospital ? (float) $hospital->pos_x : self::HOSPITAL_POS[0];
|
|
$z = $hospital ? (float) ($hospital->pos_z + $hospital->depth / 2 + 3) : self::HOSPITAL_POS[1];
|
|
|
|
$this->forceFill([
|
|
'state' => self::STATE_HOSPITAL,
|
|
'hospitalized_until' => now()->addSeconds(self::HOSPITAL_STAY_SECONDS),
|
|
'pos_x' => $x,
|
|
'pos_z' => $z,
|
|
'pos_y' => 0,
|
|
])->save();
|
|
|
|
return [$x, $z];
|
|
}
|
|
|
|
/**
|
|
* Release the character from the hospital when the one-hour stay has elapsed.
|
|
*/
|
|
public function releaseIfHospitalStayOver(): bool
|
|
{
|
|
if ($this->state !== self::STATE_HOSPITAL) {
|
|
return false;
|
|
}
|
|
|
|
if ($this->hospitalized_until === null || $this->hospitalized_until->isPast()) {
|
|
$this->forceFill([
|
|
'state' => self::STATE_AWAKE,
|
|
'hospitalized_until' => null,
|
|
])->save();
|
|
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
}
|