514 lines
14 KiB
PHP
514 lines
14 KiB
PHP
<?php
|
|
|
|
require_once __DIR__ . '/database.php';
|
|
|
|
define('VERSION', '1.2.5');
|
|
define('BUILD', 'Reawaken');
|
|
define('START', microtime(true));
|
|
|
|
/**
|
|
* Open or get SQLite database connection.
|
|
*/
|
|
function db(): Database
|
|
{
|
|
return $GLOBALS['database'] ??= new Database(__DIR__ . '/../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 "../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,
|
|
substr($uglydate, 5, 2), // Month
|
|
substr($uglydate, 8, 2), // Day
|
|
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,
|
|
"totaltime" => round(microtime(true) - START, 4),
|
|
"numqueries" => db()->count,
|
|
"version" => VERSION,
|
|
"build" => BUILD
|
|
]);
|
|
|
|
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)) {
|
|
$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');
|
|
}
|