$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 = ''; } /** * 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; } function create_stat_table(): string { $stat_table = '
' . '
' . '
' . generate_stat_bar((int)user()->currenthp, (int)user()->maxhp) . '
HP
' . '
' . generate_stat_bar((int)user()->currentmp, (int)user()->maxmp) . '
MP
' . '
' . generate_stat_bar((int)user()->currenttp, (int)user()->maxtp) . '
TP
' . '
' . '
'; 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'); }