Files
City10/app/Models/Character.php
marcocosta10000 81be63b4c3 feat(ai): add per-character AI model assignment and identity profiles
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.
2026-10-31 18:35:57 -03:00

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;
}
}