Valithor Obsidion 4d5c345528 functions.php
2025-08-14 15:47:25 -04:00

725 lines
20 KiB
PHP

<?php
declare(strict_types=1);
/*
* This file is a part of the Dragon-Knight project.
*
* Copyright (c) 2024-present Sharkk
*
* This file is subject to the MIT license that is bundled
* with this source code in the LICENSE.md file.
*/
namespace DragonKnight;
use DragonKnight\Actions\Explore;
use DragonKnight\Actions\Towns;
use DragonKnight\Models\User;
function index(): string|false
{
$page = false;
if (user()->currentaction === 'In Town') {
$page = Towns::town();
} elseif (user()->currentaction === 'Exploring') {
$page = Explore::explore();
} elseif (user()->currentaction === 'Fighting') {
redirect('/fight');
}
return $page;
}
/**
* Show the user their position on the current world map. Only works with a game size of 250 and the default towns 😅.
*/
function show_map()
{
$pos = sprintf(
'<div style="position: absolute; width: 5px; height: 5px; border-radius: 1000px; border: solid 1px black; background-color: red; left: %dpx; top: %dpx;"></div>',
round(258 + user()->longitude * (500 / 500) - 3),
round(258 - user()->latitude * (500 / 500) - 3)
);
echo render('layouts/minimal', [
'content' => '<img src="/img/map.gif" alt="Map">'.$pos,
'title' => 'Map',
]);
}
/**
* Show a character's info. Defaults to the currently logged in user.
*/
function show_character_info(int $id = 0): string
{
$user = $id !== 0 ? User::find($id) : user();
if ($user === false) {
exit('Failed to show info for user ID '.$id);
}
$level = db()->query("SELECT `{$user->charclass}_exp` FROM levels WHERE id=? LIMIT 1;", [$user->level + 1])->fetchArray(SQLITE3_ASSOC);
$spells = $user->spells();
$magic_list = 'None';
if (! empty($spells)) {
$magic_list = '';
foreach ($spells as $spell) {
$magic_list .= $spell['name'].'<br>';
}
}
$showchar = render('show_char', [
'char' => $user,
'level' => $level,
'magic_list' => $magic_list,
]);
return render('layouts/minimal', ['content' => $showchar, 'title' => $user->username.' Information']);
}
/**
* Handle a POST request to send a new babblebox message.
*/
function babblebox()
{
if (is_post()) {
$content = trim($_POST['babble']);
if (! empty($content)) {
db()->query(
'INSERT INTO babble (posttime, author, babble) VALUES (CURRENT_TIMESTAMP, ?, ?);',
[user()->username, $content]
);
}
return babblebox_messages();
}
}
/**
* The handler that is polled by HTMX for new babblebox messages.
*/
function babblebox_messages(): string
{
if (user() === false) {
return '';
}
$query = db()->query('SELECT * FROM babble ORDER BY id ASC LIMIT 40;');
$has_chats = false;
$messages = '';
while ($row = $query->fetchArray(SQLITE3_ASSOC)) {
$has_chats = true;
$messages .= '<div class="message">[<b>'.$row['author'].'</b>] '.make_safe($row['babble']).'</div>';
}
if (! $has_chats) {
$messages = 'There are no messages. :(';
}
return $messages;
}
/**
* Open or get SQLite database connection.
*/
function db(): Database
{
if (! is_dir($path = getcwd().'/db')) {
error_log('Database folder not found at '.$path.'. Please run the installer first.');
exit();
}
return $GLOBALS['database'] ??= new Database(getcwd().'/db/database.db');
}
/**
* Redirect to a different URL, exit.
*/
function redirect(string $location): void
{
if (is_htmx()) {
$target = isset($_SERVER['HTTP_HX_TARGET']) ? '#'.$_SERVER['HTTP_HX_TARGET'] : '#middle';
$json = json_encode(['path' => $location, 'target' => $target]);
header("HX-Location: $json");
} else {
header("Location: $location");
}
exit;
}
/**
* Render a view with the given data. Can be used redundantly within the template.
*/
function render(string $path_to_base_view, array $data = []): string|false
{
ob_start();
extract($data);
require __DIR__."/../templates/$path_to_base_view.php";
return ob_get_clean();
}
/**
* Replace tags with given content.
*/
function parse(string $template, array $array): string
{
return strtr($template, array_combine(
array_map(fn ($key) => "{{{$key}}}", array_keys($array)),
array_values($array)
));
}
/**
* Change the SQLite3 datetime format (YYYY-MM-DD HH:MM:SS) into something friendlier.
*/
function pretty_date(string $uglydate): string
{
return date('l, F j, Y', mktime(
0,
0,
0,
(int) substr($uglydate, 5, 2), // Month
(int) substr($uglydate, 8, 2), // Day
(int) substr($uglydate, 0, 4) // Year
));
}
/**
* Use htmlentities with UTF-8 encoding to ensure we're only outputting healthy, safe and effective HTML.
*/
function make_safe(string $content): string
{
return htmlentities($content, ENT_QUOTES, 'UTF-8');
}
/**
* Finalize admin page and output to browser.
*/
function display_admin($content, $title)
{
echo render('layouts/admin', [
'title' => $title,
'content' => $content,
]);
exit;
}
/**
* Determine what game skin to use. If a user is logged in then it uses their setting, otherwise defaults to 0 (retro).
*/
function game_skin(): int
{
return user() !== false ? user()->game_skin : 0;
}
/**
* Get a town's data by it's coordinates.
*/
function get_town_by_xy(int $x, int $y): array|false
{
$cache_tag = "town-$x-$y";
if (! isset($GLOBALS['cache'][$cache_tag])) {
$query = db()->query('SELECT * FROM towns WHERE longitude = ? AND latitude = ? LIMIT 1;', [$x, $y]);
if ($query === false) {
return false;
}
$GLOBALS['cache'][$cache_tag] = $query->fetchArray(SQLITE3_ASSOC);
}
return $GLOBALS['cache'][$cache_tag];
}
/**
* Get a town's data by it's ID.
*/
function get_town_by_id(int $id): array|false
{
$query = db()->query('SELECT * FROM towns WHERE id = ? LIMIT 1;', [$id]);
if ($query === false) {
return false;
}
return $query->fetchArray(SQLITE3_ASSOC);
}
/**
* Get a user's data by their ID, username or email.
*/
function get_user(int|string $id, string $data = '*'): array|false
{
$query = db()->query(
"SELECT $data FROM users WHERE id=? OR username=? COLLATE NOCASE OR email=? COLLATE NOCASE LIMIT 1;",
[$id, $id, $id]
);
if ($query === false) {
return false;
}
return $query->fetchArray(SQLITE3_ASSOC);
}
/**
* Get an item by it's ID.
*/
function get_item(int $id): array|false
{
$query = db()->query('SELECT * FROM items WHERE id=? LIMIT 1;', [$id]);
if ($query === false) {
return false;
}
return $query->fetchArray(SQLITE3_ASSOC);
}
/**
* Get a drop by it's ID.
*/
function get_drop(int $id): array|false
{
$query = db()->query('SELECT * FROM drops WHERE id=? LIMIT 1;', [$id]);
if ($query === false) {
return false;
}
return $query->fetchArray(SQLITE3_ASSOC);
}
/**
* Get a spell by it's ID.
*/
function get_spell(int $id): array|false
{
$query = db()->query('SELECT * FROM spells WHERE id=? LIMIT 1;', [$id]);
if ($query === false) {
return false;
}
return $query->fetchArray(SQLITE3_ASSOC);
}
/**
* Get a monster by it's ID.
*/
function get_monster(int $id): array|false
{
$query = db()->query('SELECT * FROM monsters WHERE id=? LIMIT 1;', [$id]);
if ($query === false) {
return false;
}
return $query->fetchArray(SQLITE3_ASSOC);
}
/**
* Translate a Specials keyword to it's string.
*/
function special_to_string(string $special): string
{
return match ($special) {
'maxhp' => 'Max HP',
'maxmp' => 'Max MP',
'maxtp' => 'Max TP',
'goldbonus' => 'Gold Bonus (%)',
'expbonus' => 'Experience Bonus (%)',
'strength' => 'Strength',
'dexterity' => 'Dexterity',
'attackpower' => 'Attack Power',
'defensepower' => 'Defense Power',
default => $special
};
}
/**
* Generate a pretty dope token.
*/
function token($length = 32): string
{
return bin2hex(random_bytes($length));
}
/**
* Validate any given array of data against rules. Returns [valid, data, error]. Data contains the trimmed
* values from the input array. Note: all fields with rules are assumed to be required, unless the optional
* rule is used.
*
* Example: ['required', 'no-trim', 'length:5-20', 'alphanum-spaces']
*/
function validate(array $input_data, array $rules): array
{
$data = [];
$errors = [];
foreach ($rules as $field => $field_rules) {
$value = $input_data[$field] ?? null;
$field_name = ucfirst(str_replace('_', ' ', $field));
$is_required = true;
$default_value = null;
if (in_array('optional', $field_rules)) {
$is_required = false;
}
foreach ($field_rules as $rule) {
if (strpos($rule, 'default:') === 0) {
$default_value = substr($rule, 8);
break;
}
}
if (($value === null || $value === '') && $default_value !== null) {
$value = $default_value;
}
if (($value === null || $value === '') && ! $is_required) {
continue;
}
if ($is_required && ($value === null || $value === '')) {
$errors[$field][] = "{$field_name} is required.";
continue;
}
if (! in_array('no-trim', $field_rules)) {
$value = trim($value);
}
$data[$field] = $value;
foreach ($field_rules as $rule) {
// Parse rule and arguments
if (strpos($rule, ':') !== false) {
list($rule_name, $rule_args) = explode(':', $rule, 2);
} else {
$rule_name = $rule;
$rule_args = null;
}
if ($rule_name === 'optional') {
continue;
}
switch ($rule_name) {
case 'bool':
if (! isset($input_data[$field]) || empty($value)) {
$value = false;
} else {
$value = filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
if ($value === null) {
$errors[$field][] = "{$field_name} must be a valid boolean value.";
}
}
break;
case 'length':
list($min, $max) = explode('-', $rule_args);
$len = strlen((string) $value);
if ($len < $min || $len > $max) {
$errors[$field][] = "{$field_name} must be between {$min} and {$max} characters.";
}
break;
case 'alphanum':
if (! preg_match('/^[a-zA-Z0-9]+$/', $value)) {
$errors[$field][] = "{$field_name} must contain only letters and numbers.";
}
break;
case 'alpha':
if (! preg_match('/^[a-zA-Z]+$/', $value)) {
$errors[$field][] = "{$field_name} must contain only letters and numbers.";
}
break;
case 'alphanum-spaces':
if (! preg_match('/^[a-zA-Z0-9\s_]+$/', $value)) {
$errors[$field][] = "{$field_name} must contain only letters, numbers, spaces, and underscores.";
}
break;
case 'alpha-spaces':
if (! preg_match('/^[a-zA-Z\s_]+$/', $value)) {
$errors[$field][] = "{$field_name} must contain only letters, numbers, spaces, and underscores.";
}
break;
case 'email':
if (! filter_var($value, FILTER_VALIDATE_EMAIL)) {
$errors[$field][] = "{$field_name} must be a valid email address.";
}
break;
case 'int':
if (filter_var($value, FILTER_VALIDATE_INT) === false) {
$errors[$field][] = "{$field_name} must be an integer.";
}
break;
case 'min':
if ($value < $rule_args) {
$errors[$field][] = "{$field_name} must be at least {$rule_args}.";
}
break;
case 'max':
if ($value > $rule_args) {
$errors[$field][] = "{$field_name} must be no more than {$rule_args}.";
}
break;
case 'regex':
if (! preg_match($rule_args, $value)) {
$errors[$field][] = "{$field_name} does not match the required pattern.";
}
break;
case 'in':
$options = explode(',', $rule_args);
if (! in_array($value, $options)) {
$errors[$field][] = "{$field_name} must be one of: ".implode(', ', $options);
}
break;
case 'confirm':
$field_to_confirm = substr($field, 8);
$confirm_value = $data[$field_to_confirm] ?? '';
$confirm_field_name = ucfirst(str_replace('_', ' ', $field_to_confirm));
if ($value !== $confirm_value) {
$errors[$field][] = "{$field_name} must match {$confirm_field_name}.";
}
break;
case 'unique':
list($table, $column) = explode(',', $rule_args, 2);
if (db()->exists($table, $column, $value)) {
$errors[$field][] = "{$field_name} must be unique.";
}
break;
}
}
}
foreach ($input_data as $field => $value) {
if (! isset($data[$field])) {
$data[$field] = trim($value);
}
}
return [
'valid' => empty($errors),
'data' => $data,
'errors' => $errors,
];
}
/**
* Generates a ul list from `validate()`'s errors.
*/
function ul_from_validate_errors(array $errors): string
{
$string = '<ul>';
foreach ($errors as $field => $errors) {
$string .= '<li>';
foreach ($errors as $error) {
$string .= $error;
}
$string .= '</li>';
}
return $string.'</ul>';
}
/**
* Load the environment variables from the .env file.
*/
function env_load(string $filePath): void
{
if (! file_exists($filePath)) {
throw new \Exception('The .env file does not exist. (el)');
}
$lines = file($filePath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
foreach ($lines as $line) {
$line = trim($line);
// Skip lines that are empty after trimming or are comments
if ($line === '' || str_starts_with($line, '#')) {
continue;
}
// Skip lines without an '=' character
if (strpos($line, '=') === false) {
continue;
}
[$name, $value] = explode('=', $line, 2);
$name = trim($name);
$value = trim($value, " \t\n\r\0\x0B\"'"); // Trim whitespace and quotes
if (! array_key_exists($name, $_SERVER) && ! array_key_exists($name, $_ENV)) {
putenv("$name=$value");
$_ENV[$name] = $value;
$_SERVER[$name] = $value;
}
}
}
/**
* Retrieve an environment variable.
*/
function env(string $key, mixed $default = null): mixed
{
$v = $_ENV[$key] ?? $_SERVER[$key] ?? (getenv($key) ?: $default);
return match (true) {
$v === 'true' => true,
$v === 'false' => false,
is_numeric($v) => (int) $v,
is_float($v) => (float) $v,
default => $v
};
}
/**
* Get the data on spells from a given list of IDs.
*/
function get_spells_from_list(array|string $spell_ids): array|false
{
if (is_string($spell_ids)) {
$spell_ids = explode(',', $spell_ids);
}
$placeholders = implode(',', array_fill(0, count($spell_ids), '?'));
$query = db()->query("SELECT id, name, type FROM spells WHERE id IN($placeholders)", $spell_ids);
if ($query === false) {
return false;
}
$rows = [];
while ($row = $query->fetchArray(SQLITE3_ASSOC)) {
$rows[] = $row;
}
return ! empty($rows) ? $rows : false;
}
function generate_stat_bar(int $current, int $max): string
{
$percent = $max > 0 ? round(max(0, $current) / $max * 100, 4) : 0;
if ($percent < 0) {
$percent = 0;
}
if ($percent > 100) {
$percent = 100;
}
$color = $percent >= 66 ? 'green' : ($percent >= 33 ? 'yellow' : 'red');
return <<<HTML
<div class="stat-bar" style="width: 15px; height: 100px; border: solid 1px black;">
<div style="height: $percent%; background-image: url(/img/bars_$color.gif);"></div>
</div>
HTML;
}
function create_stat_table(): string
{
$stat_table = '<div class="stat-table">'.
'<div class="stat-row">'.
'<div class="stat-col">'.generate_stat_bar((int) user()->currenthp, (int) user()->maxhp).'<div>HP</div></div>'.
'<div class="stat-col">'.generate_stat_bar((int) user()->currentmp, (int) user()->maxmp).'<div>MP</div></div>'.
'<div class="stat-col">'.generate_stat_bar((int) user()->currenttp, (int) user()->maxtp).'<div>TP</div></div>'.
'</div>'.
'</div>';
return $stat_table;
}
/**
* Returns the user in the GLOBALS state, if there is one. If not, populates it if there is a SESSION user_id.
*/
function user(): User|false
{
$GLOBALS['state']['user'] ??= (isset($_SESSION['user_id']) ? User::find($_SESSION['user_id']) : false);
return $GLOBALS['state']['user'];
}
/**
* Determine whether a request is from HTMX. If HTMX is trying to restore history, we will say no in order to render
* full pages.
*/
function is_htmx(): bool
{
if (isset($_SERVER['HTTP_HX_HISTORY_RESTORE_REQUEST']) && $_SERVER['HTTP_HX_HISTORY_RESTORE_REQUEST'] === 'true') {
return false;
}
return isset($_SERVER['HTTP_HX_REQUEST']) && $_SERVER['HTTP_HX_REQUEST'] === 'true';
}
/**
* Return whether the request is POST.
*/
function is_post(): bool
{
return $_SERVER['REQUEST_METHOD'] === 'POST';
}
/**
* Get the current page title per updates. Optionally set a new title.
*/
function page_title(string $new_title = ''): string
{
if ($new_title) {
return $GLOBALS['state']['new-page-title'] = $new_title;
}
return $GLOBALS['state']['new-page-title'] ?? env('game_name');
}
/**
* Render the response for the browser based on the request context. The main point is to seperate the handling
* of HTMX responses from normal responses.
*/
function render_response(array $uri, string $content): string
{
if ($uri[0] === 'babblebox') {
return $content;
}
if (is_htmx()) {
header('HX-Push-Url: '.$_SERVER['REQUEST_URI']);
$content .= '<title>'.page_title().'</title>';
$content .= Render::debug_db_info();
if (env('debug', false)) {
$content .= Render::debug_query_log();
}
if ($GLOBALS['state']['user-state-changed'] ?? false) {
$content .= Render::right_nav();
$content .= Render::left_nav();
}
}
return Render::content($content, page_layout());
}
/**
* Get/set page layout through GLOBALS state.
*/
function page_layout(string $layout = ''): string
{
if ($layout === '') {
return $GLOBALS['state']['page-layout'] ?? 'layouts/primary';
}
return $GLOBALS['state']['page-layout'] = $layout;
}