From feba005335c11c760ed69c8540e02302cefa0a0d Mon Sep 17 00:00:00 2001 From: Valithor Obsidion Date: Thu, 14 Aug 2025 13:55:37 -0400 Subject: [PATCH] Creates classes, updates folders and scope --- .gitignore | 36 +- .php-cs-fixer.dist.php | 102 +++ composer.json | 32 + install.php | 63 ++ phpdoc.dist.xml | 27 + pint.json | 6 + public/index.php | 155 +--- src/DragonKnight/Actions/Admin.php | 603 +++++++++++++ src/DragonKnight/Actions/Explore.php | 89 ++ src/DragonKnight/Actions/Fight.php | 558 ++++++++++++ src/DragonKnight/Actions/Forum.php | 137 +++ src/DragonKnight/Actions/Heal.php | 55 ++ src/DragonKnight/Actions/Help.php | 517 ++++++++++++ src/DragonKnight/Actions/Install.php | 796 ++++++++++++++++++ src/DragonKnight/Actions/Towns.php | 435 ++++++++++ src/DragonKnight/Actions/Users.php | 260 ++++++ src/{auth.php => DragonKnight/Auth.php} | 88 +- src/DragonKnight/Bootstrap.php | 76 ++ .../Database.php} | 82 +- src/DragonKnight/Lib.php | 731 ++++++++++++++++ src/DragonKnight/Mail.php | 132 +++ src/DragonKnight/Math.php | 120 +++ src/DragonKnight/Models/Model.php | 66 ++ src/DragonKnight/Models/User.php | 116 +++ src/DragonKnight/Render.php | 91 ++ src/DragonKnight/Router.php | 212 +++++ src/actions/admin.php | 566 ------------- src/actions/explore.php | 69 -- src/actions/fight.php | 451 ---------- src/actions/forum.php | 115 --- src/actions/heal.php | 34 - src/actions/help.php | 494 ----------- src/actions/install.php | 767 ----------------- src/actions/towns.php | 401 --------- src/actions/users.php | 226 ----- src/bootstrap.php | 76 -- src/lib.php | 546 ------------ src/mail.php | 109 --- src/math.php | 98 --- src/models/model.php | 46 - src/models/user.php | 85 -- src/render.php | 62 -- src/router.php | 187 ---- templates/admin/edit_drop.php | 14 +- templates/admin/edit_item.php | 20 +- templates/admin/edit_level.php | 24 +- templates/admin/edit_monster.php | 28 +- templates/admin/edit_spell.php | 22 +- templates/admin/edit_town.php | 20 +- templates/admin/edit_user.php | 106 +-- templates/admin/main_settings.php | 34 +- templates/babblebox.php | 2 +- templates/fight.php | 10 +- templates/layouts/admin.php | 14 +- templates/layouts/help.php | 8 +- templates/layouts/minimal.php | 4 +- templates/layouts/primary.php | 22 +- templates/left_nav.php | 46 +- templates/register.php | 8 +- templates/right_nav.php | 48 +- templates/show_char.php | 52 +- templates/towns.php | 8 +- 62 files changed, 5652 insertions(+), 4755 deletions(-) create mode 100644 .php-cs-fixer.dist.php create mode 100644 composer.json create mode 100644 install.php create mode 100644 phpdoc.dist.xml create mode 100644 pint.json create mode 100644 src/DragonKnight/Actions/Admin.php create mode 100644 src/DragonKnight/Actions/Explore.php create mode 100644 src/DragonKnight/Actions/Fight.php create mode 100644 src/DragonKnight/Actions/Forum.php create mode 100644 src/DragonKnight/Actions/Heal.php create mode 100644 src/DragonKnight/Actions/Help.php create mode 100644 src/DragonKnight/Actions/Install.php create mode 100644 src/DragonKnight/Actions/Towns.php create mode 100644 src/DragonKnight/Actions/Users.php rename src/{auth.php => DragonKnight/Auth.php} (50%) create mode 100644 src/DragonKnight/Bootstrap.php rename src/{database.php => DragonKnight/Database.php} (55%) create mode 100644 src/DragonKnight/Lib.php create mode 100644 src/DragonKnight/Mail.php create mode 100644 src/DragonKnight/Math.php create mode 100644 src/DragonKnight/Models/Model.php create mode 100644 src/DragonKnight/Models/User.php create mode 100644 src/DragonKnight/Render.php create mode 100644 src/DragonKnight/Router.php delete mode 100644 src/actions/admin.php delete mode 100644 src/actions/explore.php delete mode 100644 src/actions/fight.php delete mode 100644 src/actions/forum.php delete mode 100644 src/actions/heal.php delete mode 100644 src/actions/help.php delete mode 100644 src/actions/install.php delete mode 100644 src/actions/towns.php delete mode 100644 src/actions/users.php delete mode 100644 src/bootstrap.php delete mode 100644 src/lib.php delete mode 100644 src/mail.php delete mode 100644 src/math.php delete mode 100644 src/models/model.php delete mode 100644 src/models/user.php delete mode 100644 src/render.php delete mode 100644 src/router.php diff --git a/.gitignore b/.gitignore index 4cede8b..0edd5d5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,37 @@ +# general +.env +.DS_Store +test*.php +.idea +.phplint-cache +dump.html +.vscode + +# dragon-knight .installed +logs database.db database.db-* -.env -logs + +# phive +/tools +.phive + +# composer +/vendor +composer.lock + +# docs +/build +.phpdoc + +# php-cs-fixer +.php_cs.cache +.php_cs +.php-cs-fixer.php +.php-cs-fixer.cache + +# phpunit +phpunit.log +/.phpunit* +/coverage diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php new file mode 100644 index 0000000..fde4dba --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,102 @@ + ['spacing' => 'none'], + 'phpdoc_no_alias_tag' => ['replacements' => ['type' => 'var']], + 'array_syntax' => ['syntax' => 'short'], + 'binary_operator_spaces' => ['align_double_arrow' => true, 'align_equals' => true], + 'header_comment' => ['header' => $header], + 'indentation_type' => true, + 'phpdoc_align' => [ + 'align' => 'vertical', + 'tags' => ['param', 'property', 'property-read', 'property-write', 'return', 'throws', 'type', 'var', 'method'], + ], + 'blank_line_before_statement' => ['statements' => ['return']], + 'constant_case' => ['case' => 'lower'], + 'echo_tag_syntax' => ['format' => 'long'], + 'trailing_comma_in_multiline' => ['elements' => ['arrays']], +]; + +foreach ($fixers as $fix) { + $rules[$fix] = true; +} + +$config = new PhpCsFixer\Config(); + +return $config + ->setRules($rules) + ->setFinder( + PhpCsFixer\Finder::create() + ->exclude('examples') + ->in(__DIR__) + ); diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..7b643d4 --- /dev/null +++ b/composer.json @@ -0,0 +1,32 @@ +{ + "name": "valgorithms/dragon-knight", + "description": "@todo", + "license": "MIT", + "authors": [ + { + "name": "Valithor Obsidion", + "email": "valithor@discordphp.org" + } + ], + "require": { + "php": "^8.3", + "team-reflex/discord-php": "^10.18.45" + }, + "autoload": { + "psr-4": { + "DragonKnight\\": "src/DragonKnight" + } + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3", + "phpunit/phpunit": "^9.4.4", + "laravel/pint": "^1.21" + }, + "scripts": { + "pint": ["./vendor/bin/pint --config ./pint.json ./src"], + "cs": ["./vendor/bin/php-cs-fixer fix"], + "cs-unsupported": ["./vendor/bin/php-cs-fixer fix --allow-unsupported-php-version yes"], + "unit": ["./vendor/bin/phpunit --testdox"], + "coverage": ["XDEBUG_MODE=coverage ./vendor/bin/phpunit --coverage-html coverage --testdox"] + } +} diff --git a/install.php b/install.php new file mode 100644 index 0000000..a173918 --- /dev/null +++ b/install.php @@ -0,0 +1,63 @@ + $line && ! str_starts_with($line, '#')); + + array_walk($filteredLines, function ($line) { + [$name, $value] = array_map('trim', explode('=', $line, 2)); + if (! array_key_exists($name, $_ENV)) { + putenv(sprintf('%s=%s', $name, $value)); + } + }); +} +loadEnv(getcwd().'/.env'); + +if (! $autoloader = require file_exists(__DIR__.'/vendor/autoload.php') ? __DIR__.'/vendor/autoload.php' : __DIR__.'/../../autoload.php') { + throw new \Exception('Composer autoloader not found. Run `composer install` and try again.'); +} + +if (! extension_loaded('sqlite3')) { + throw new \Exception('The SQLite3 extension is required but not loaded. Please enable it in your PHP configuration.'); +} + +Install::second(); +Install::fourth([ + 'username' => 'admin', + 'email' => 'admin@example.com', + 'confirm_email' => 'admin@example.com', + 'password' => 'password', + 'confirm_password' => 'password', + 'charclass' => 'Warrior', // Mage, Warrior, Paladin +]); +Install::fifth(); diff --git a/phpdoc.dist.xml b/phpdoc.dist.xml new file mode 100644 index 0000000..fe35c30 --- /dev/null +++ b/phpdoc.dist.xml @@ -0,0 +1,27 @@ + + + DiscordPHP Documentation + + build + + + + + src + + reference + + + + guide + + guide + + + + diff --git a/pint.json b/pint.json new file mode 100644 index 0000000..49c5a7f --- /dev/null +++ b/pint.json @@ -0,0 +1,6 @@ +{ + "preset": "psr12", + "rules": { + "declare_strict_types": true + } +} \ No newline at end of file diff --git a/public/index.php b/public/index.php index c70c28f..3d485ac 100644 --- a/public/index.php +++ b/public/index.php @@ -1,135 +1,60 @@ get('/', 'index'); -$r->post('/move', 'Explore\move'); -$r->get('/spell/:id', 'healspells'); -$r->get('/character', 'show_character_info'); -$r->get('/character/:id', 'show_character_info'); -$r->get('/showmap', 'show_map'); -$r->form('/babblebox', 'babblebox'); -$r->get('/babblebox/messages', 'babblebox_messages'); +$r->get('/', 'DragonKnight\Lib::index'); +$r->post('/move', 'DragonKnight\Actions\Explore::move'); +$r->get('/spell/:id', 'DragonKnight\Actions\Heal::healspells'); +$r->get('/character', 'DragonKnight\Lib::show_character_info'); +$r->get('/character/:id', 'DragonKnight\Lib::show_character_info'); +$r->get('/showmap', 'DragonKnight\Lib::show_map'); +$r->form('/babblebox', 'DragonKnight\Lib::babblebox'); +$r->get('/babblebox/messages', 'DragonKnight\Lib::babblebox_messages'); -Towns\register_routes($r); -Fights\register_routes($r); -Users\register_routes($r); -Help\register_routes($r); -Forum\register_routes($r); -Install\register_routes($r); -Admin\register_routes($r); +Towns::register_routes($r); +Fight::register_routes($r); +Users::register_routes($r); +Help::register_routes($r); +Forum::register_routes($r); +Install::register_routes($r); +Admin::register_routes($r); /* - NINJA! 🥷 + NINJA! 🥷 */ -$r->get('/ninja', function() { - exit('NINJA! 🥷'); +$r->get('/ninja', function () { + exit('NINJA! 🥷'); }); // [code, handler, params, middleware] $l = $r->lookup($_SERVER['REQUEST_METHOD'], $_SERVER['REQUEST_URI']); -if (is_int($l)) exit("Error: $l"); -echo render_response($uri, $l['handler'](...$l['params'] ?? [])); -exit; - -/** - * Return a page for a couple generic actions. - */ -function index(): string -{ - if (user()->currentaction === "In Town") { - $page = Towns\town(); - } elseif (user()->currentaction === "Exploring") { - $page = Explore\explore(); - } elseif (user()->currentaction === "Fighting") { - redirect('/fight'); - } - - return $page; -} - -/** - * 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'].'
'; - } - - $showchar = render('show_char', [ - 'char' => $user, - 'level' => $level, - 'magic_list' => $magic_list - ]); - return render('layouts/minimal', ['content' => $showchar, 'title' => $user->username.' Information']); -} - -/** - * 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( - '
', - round(258 + user()->longitude * (500 / 500) - 3), - round(258 - user()->latitude * (500 / 500) - 3) - ); - - echo render('layouts/minimal', [ - 'content' => 'Map'.$pos, - 'title' => 'Map' - ]); -} - -/** - * 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 .= '
[' . $row['author'] . '] ' . make_safe($row['babble']) . '
'; - } - if (!$has_chats) $messages = 'There are no messages. :('; - return $messages; +if (is_int($l)) { + exit("Error: $l"); } +echo Lib::render_response($uri, $l['handler'](...$l['params'] ?? [])); diff --git a/src/DragonKnight/Actions/Admin.php b/src/DragonKnight/Actions/Admin.php new file mode 100644 index 0000000..9de4de4 --- /dev/null +++ b/src/DragonKnight/Actions/Admin.php @@ -0,0 +1,603 @@ +authlevel === 1) { + $r->get('/admin', 'Admin\donothing'); + + $r->form('/admin/main', 'Admin\primary'); + + $r->get('/admin/items', 'Admin\items'); + $r->form('/admin/items/:id', 'Admin\edit_item'); + + $r->get('/admin/drops', 'Admin\drops'); + $r->form('/admin/drops/:id', 'Admin\edit_drop'); + + $r->get('/admin/towns', 'Admin\towns'); + $r->form('/admin/towns/:id', 'Admin\edit_town'); + + $r->get('/admin/monsters', 'Admin\monsters'); + $r->form('/admin/monsters/:id', 'Admin\edit_monster'); + + $r->get('/admin/levels', 'Admin\levels'); + $r->post('/admin/levels', 'Admin\edit_level'); + + $r->get('/admin/spells', 'Admin\spells'); + $r->form('/admin/spells/:id', 'Admin\edit_spell'); + + $r->get('/admin/users', 'Admin\users'); + $r->form('/admin/users/:id', 'Admin\edit_user'); + + $r->form('/admin/news', 'Admin\add_news'); + } + + return $r; + } + + /** + * Home page for the admin panel. + */ + public static function donothing(): string + { + Lib::page_title('Admin'); + + return <<
+ Please note that the control panel has been created mostly as a shortcut for certain individual settings. It is + meant for use primarily with editing one thing at a time. If you need to completely replace an entire table + (say, to replace all stock monsters with your own new ones), it is suggested that you use a more in-depth + database tool such as SQLite Browser. +

+ Also, you should be aware that certain portions of the DK code are dependent on the formatting of certain + database results (for example, the special attributes on item drops). While I have attempted to point these out + throughout the admin script, you should definitely pay attention and be careful when editing some fields, + because mistakes in the database content may result in script errors or your game breaking completely. + HTML; + } + + /** + * Main settings that get written to .env. + */ + public static function primary(): string + { + if (Lib::is_post()) { + $form = Lib::validate($_POST, [ + 'gamename' => ['alphanum-spaces'], + 'gamesize' => ['int', 'min:5'], + 'class1name' => ['alpha-spaces'], + 'class2name' => ['alpha-spaces'], + 'class3name' => ['alpha-spaces'], + 'gameopen' => ['bool'], + 'verifyemail' => ['bool'], + 'shownews' => ['bool'], + 'showonline' => ['bool'], + 'showbabble' => ['bool'], + ]); + + if ($form['valid']) { + $form = $form['data']; + if (($form['gamesize'] % 5) != 0) { + exit('Map size must be divisible by five.'); + } + + // @todo + // write changes to .env + + $page = 'Main settings updated.'; + } else { + $error_list = Lib::ul_from_validate_errors($form['errors']); + $page = <<Errors:
+
{$error_list}

+ Please go back and try again. + HTML; + } + } else { + $page = Lib::render('admin/main_settings'); + } + + Lib::page_title('Admin: Main Settings'); + + return $page; + } + + /** + * Show the full list of items that can be edited. + */ + public static function items(): string + { + $items = Lib::db()->query('SELECT * FROM items ORDER BY id;'); + $page = "

Edit Items

Click an item's name or ID to edit it.

\n"; + $page .= self::build_bulk_table($items, 'name', '/admin/items'); + + Lib::page_title('Admin: Items'); + + return $page; + } + + /** + * Shows the form for editing an item via GET, processes edits via POST. + */ + public static function edit_item(int $id): string + { + $item = Lib::get_item($id); + + $page = Lib::is_post() + ? self::handle_edit_form($id, 'items', Lib::validate($_POST, [ + 'name' => [], + 'type' => ['int', 'in:1,2,3'], + 'buycost' => ['int', 'min:0'], + 'attribute' => ['int', 'min:0'], + 'special' => ['default:X'], + ])) + : Lib::render('admin/edit_item', ['item' => $item]); + + Lib::page_title('Admin: Editing '.$item['name']); + + return $page; + } + + /** + * Show the full list of drops that can be edited. + */ + public static function drops() + { + $drops = Lib::db()->query('SELECT * FROM drops ORDER BY id;'); + $page = "

Edit Drops

Click an item's name to edit it.

\n"; + $page .= self::build_bulk_table($drops, 'name', '/admin/drops'); + + Lib::page_title('Admin: Drops'); + + return $page; + } + + /** + * Show the form to edit drops via GET, process those edits via POST. + */ + public static function edit_drop(int $id): string + { + $drop = Lib::get_drop($id); + + if (Lib::is_post()) { + $page = self::handle_edit_form($id, 'drops', Lib::validate($_POST, [ + 'name' => [], + 'mlevel' => ['int', 'min:1'], + 'attribute1' => [], + 'attribute2' => ['default:X'], + ])); + } else { + $page = Lib::render('admin/edit_drop', ['drop' => $drop]); + } + + Lib::page_title('Admin: Editing '.$drop['name']); + + return $page; + } + + /** + * Generate the list of towns that can be edited. + */ + public static function towns(): string + { + $towns = Lib::db()->query('SELECT * FROM towns ORDER BY id;'); + $page = "

Edit Towns

Click an town's name or ID to edit it.

\n"; + $page .= self::build_bulk_table($towns, 'name', '/admin/towns'); + + Lib::page_title('Admin: Towns'); + + return $page; + } + + /** + * Save any changes to the town made. + */ + public static function edit_town(int $id): string + { + $town = Lib::get_town_by_id($id); + + if (Lib::is_post()) { + $page = self::handle_edit_form($id, 'towns', Lib::validate($_POST, [ + 'name' => [], + 'latitude' => ['int', 'min:0', 'max:'.Lib::env('game_size')], + 'longitude' => ['int', 'min:0', 'max:'.Lib::env('game_size')], + 'innprice' => ['int', 'min:0'], + 'mapprice' => ['int', 'min:0'], + 'travelpoints' => ['int', 'min:0'], + 'itemslist' => ['optional'], + ])); + } else { + $page = Lib::render('admin/edit_town', ['town' => $town]); + } + + Lib::page_title('Admin: Editing '.$town['name']); + + return $page; + } + + /** + * List the monsters available to edit. + */ + public static function monsters() + { + $max_level = Lib::db()->query('SELECT level FROM monsters ORDER BY level DESC LIMIT 1;')->fetchArray(SQLITE3_ASSOC)['level']; + $monsters = Lib::db()->query('SELECT * FROM monsters ORDER BY id;'); + + $page = '

Edit Monsters

'; + + $page .= ((Lib::env('game_size') / 5) !== $max_level) + ? 'Note: Your highest monster level does not match with your entered map size. Highest monster level should be '.(Lib::env('game_size') / 5).", yours is $max_level. Please fix this before opening the game to the public.
" + : 'Monster level and map size match. No further actions are required for map compatibility.
'; + + $page .= "Click an monster's name or ID to edit it.

\n"; + $page .= self::build_bulk_table($monsters, 'name', '/admin/monsters'); + + Lib::page_title('Admin: Monsters'); + + return $page; + } + + /** + * Handle the actual editing of the monster. + */ + public static function edit_monster(int $id): string + { + $monster = Lib::get_monster($id); + + $page = (Lib::is_post()) + ? self::handle_edit_form($id, 'monsters', Lib::validate($_POST, [ + 'name' => [], + 'maxhp' => ['int', 'min:1'], + 'maxdam' => ['int', 'min:0'], + 'armor' => ['int', 'min:0'], + 'level' => ['int', 'min:1'], + 'maxexp' => ['int', 'min:0'], + 'maxgold' => ['int', 'min:0'], + 'immune' => ['in:0,1,2'], + ])) + : Lib::render('admin/edit_monster', ['monster' => $monster]); + + Lib::page_title('Admin: Editing '.$monster['name']); + + return $page; + } + + /** + * List all spells available to edit. + */ + public static function spells(): string + { + $page = "

Edit Spells

Click an spell's name to edit it.

\n"; + + $spells = Lib::db()->query('SELECT * FROM spells ORDER BY id;'); + $page .= self::build_bulk_table($spells, 'name', '/admin/spells'); + + Lib::page_title('Admin: Spells'); + + return $page; + } + + /** + * Handle the editing of an individual spell. + */ + public static function edit_spell(int $id): string + { + $spell = Lib::get_spell($id); + + $page = (Lib::is_post()) + ? self::handle_edit_form($id, 'spells', Lib::validate($_POST, [ + 'name' => [], + 'mp' => ['int', 'min:0'], + 'attribute' => ['int', 'min:0'], + 'type' => ['in:1,2,3,4,5'], + ])) + : Lib::render('admin/edit_spell', ['spell' => $spell]); + + Lib::page_title('Admin: Editing '.$spell['name']); + + return $page; + } + + /** + * List all editable levels. + */ + public static function levels(): string + { + $max_level = Lib::db()->query('SELECT id FROM levels ORDER BY id DESC LIMIT 1;')->fetchArray(SQLITE3_ASSOC)['id']; + + $page = <<Edit Levels + Select a level number from the dropdown box to edit it.

+
+
+ + +
+ HTML; + } + + Lib::page_title('Admin: Add News'); + + return $page; + } + + /** + * Build an HTML table containing all columns and rows of a given data structure. Takes a SQLiteResult3 of a SELECT + * query. + */ + public static function build_bulk_table(SQLite3Result $query_data, string $edit_column, string $edit_link): string + { + $data = []; + $data_count = 0; + while ($row = $query_data->fetchArray(SQLITE3_ASSOC)) { + $data[$data_count++] = $row; + } + if ($data_count === 0) { + return 'No data.'; + } + + $columns = array_diff(array_keys($data[0]), ['password']); + + $html_parts = [ + '', + str_repeat('', count($columns)), + '', + ]; + + foreach ($columns as $column) { + $html_parts[] = ''; + } + $html_parts[] = ''; + + $is_edit_column = array_flip(['id', $edit_column]); + + foreach ($data as $row) { + $html_parts[] = ''; + foreach ($columns as $column) { + $name = Lib::make_safe($row[$column]); + $html_parts[] = isset($is_edit_column[$column]) + ? "" + : ""; + } + $html_parts[] = ''; + } + + $html_parts[] = '
'. + Lib::make_safe($column === 'id' ? 'ID' : ucfirst($column)). + '
{$name}{$name}
'; + + return implode('', $html_parts); + } + + /** + * Save a row of data to it's table from the data supplied. + */ + public static function save_data_row(string $table, array $data, int $id): SQLite3Result|false + { + $data = array_filter($data, fn ($value) => $value !== null && $value !== ''); + if (empty($data)) { + return false; + } + + $fields = implode(',', array_map(fn ($key) => "`$key`=?", array_keys($data))); + $values = array_values($data); + $values[] = $id; + + return Lib::db()->query("UPDATE $table SET $fields WHERE id=?", $values); + } + + /** + * Handle the result of a generic edit form. + */ + public static function handle_edit_form(int $id, string $table, array $form, string $updated_message = ''): string + { + if ($form['valid']) { + self::save_data_row($table, $form['data'], $id); + $page = $updated_message ?: ''.$form['data']['name'].' updated.'; + } else { + $error_list = Lib::ul_from_validate_errors($form['errors']); + $page = <<Errors:
+
{$error_list}

+ Please go back and try again. + HTML; + } + + return $page; + } +} diff --git a/src/DragonKnight/Actions/Explore.php b/src/DragonKnight/Actions/Explore.php new file mode 100644 index 0000000..531fff2 --- /dev/null +++ b/src/DragonKnight/Actions/Explore.php @@ -0,0 +1,89 @@ +Exploring + You are exploring the map, and nothing has happened. Continue exploring using the direction buttons or the Travel To menus. + HTML; + } + + public static function move() + { + // Early exit if fighting + if (Lib::user()->currentaction == 'Fighting') { + Lib::redirect('/fight'); + } + + // Validate direction + $form = Lib::validate($_POST, ['direction' => ['in:north,west,east,south']]); + if (! $form['valid']) { + return Lib::ul_from_validate_errors($form['errors']); + } + + // Current game state + $game_size = Lib::env('game_size'); + $latitude = Lib::user()->latitude; + $longitude = Lib::user()->longitude; + $direction = $form['data']['direction']; + + // Calculate new coordinates with boundary checks + switch ($direction) { + case 'north': + $latitude = min($latitude + 1, $game_size); + break; + case 'south': + $latitude = max($latitude - 1, -$game_size); + break; + case 'east': + $longitude = min($longitude + 1, $game_size); + break; + case 'west': + $longitude = max($longitude - 1, -$game_size); + break; + } + + // Check for town + $town = Lib::get_town_by_xy($longitude, $latitude); + if ($town !== false) { + return Towns::travelto($town['id'], false); + } + + // Determine action (1 in 5 chance of fighting) + if (rand(1, 5) === 1) { + Lib::user()->currentaction = 'Fighting'; + Lib::user()->currentfight = 1; + } else { + Lib::user()->currentaction = 'Exploring'; + } + + Lib::user()->latitude = $latitude; + Lib::user()->longitude = $longitude; + Lib::user()->save(); + + return Lib::index(); + } +} diff --git a/src/DragonKnight/Actions/Fight.php b/src/DragonKnight/Actions/Fight.php new file mode 100644 index 0000000..90d1fe6 --- /dev/null +++ b/src/DragonKnight/Actions/Fight.php @@ -0,0 +1,558 @@ +form('/fight', 'Fights\fight'); + $r->get('/victory', 'Fights\victory'); + $r->form('/drop', 'Fights\drop'); + $r->get('/dead', 'Fights\dead'); + + return $r; + } + + /** + * One big long public static function that determines the outcome of the fight. + */ + public static function fight() + { + if (Lib::user()->currentaction !== 'Fighting') { + exit('Cheat attempt detected.

Get a life, loser.'); + } + + $page = ['magiclist' => '', 'yourturn' => '', 'monsterturn' => '', 'monsterhp' => '', 'command' => '']; + $playerisdead = 0; + + // Generate spell list + $user_spells = Lib::user()->spells(); + if (! empty($user_spells)) { + $page['magiclist'] = '

'; + } + + // Determine initial combat parameters + $chancetoswingfirst = rand(1, 10) + (int) ceil(sqrt(Lib::user()->dexterity)); + if (Lib::user()->currentfight === 1) { + $maxlevel = (int) floor(max(abs(Lib::user()->latitude) + 5, abs(Lib::user()->longitude) + 5) / 5); + $minlevel = max(1, $maxlevel - 2); + + $monster = Lib::db()->query('SELECT * FROM monsters WHERE level >= ? AND level <= ? ORDER BY RANDOM() LIMIT 1;', [ + $minlevel, $maxlevel, + ])->fetchArray(SQLITE3_ASSOC); + + Lib::user()->currentmonster = $monster['id']; + Lib::user()->currentmonsterhp = rand((int) (($monster['maxhp'] / 5) * 4), $monster['maxhp']); + Lib::user()->currentmonstersleep = 0; + Lib::user()->currentmonsterimmune = $monster['immune']; + + $chancetoswingfirst = ($chancetoswingfirst > (rand(1, 7) + (int) ceil(sqrt($monster['maxdam'])))) ? 1 : 0; + } + + // Get monster statistics + $monster = Lib::get_monster(Lib::user()->currentmonster); + $page['monstername'] = $monster['name']; + + // Run action + if (isset($_POST['run'])) { + $chancetorun = rand(4, 10) + (int) ceil(sqrt(Lib::user()->dexterity)); + if ($chancetorun <= (rand(1, 5) + (int) ceil(sqrt($monster['maxdam'])))) { + $page['yourturn'] = 'You tried to run away, but were blocked in front!

'; + $page['monsterhp'] = "Monster's HP: ".Lib::user()->currentmonsterhp.'

'; + + // Monster turn logic (similar to original public static function) + $page['monsterturn'] = self::handleMonsterTurn($userrow, $monster); + + Lib::user()->currentaction = 'Exploring'; + Lib::user()->save(); + Lib::redirect('/'); + } + } + + // Fight action + if (isset($_POST['fight'])) { + // Player's attack + $min = (int) (Lib::user()->attackpower * 0.75); + $max = (int) (Lib::user()->attackpower / 3); + $tohit = (int) ceil(mt_rand(min($min, $max), max($min, $max))); + + $toexcellent = rand(1, 150); + if ($toexcellent <= sqrt(Lib::user()->strength)) { + $tohit *= 2; + $page['yourturn'] .= 'Excellent hit!
'; + } + + $min = (int) ($monster['armor'] * 0.75); + $max = (int) $monster['armor']; + $toblock = (int) ceil(rand(min($min, $max), max($min, $max)) / 3); + + $tododge = rand(1, 100); + + $monsterdamage = max(1, $tohit - $toblock); + if ($tododge <= sqrt($monster['armor'])) { + $monsterdamage = 0; + $page['yourturn'] .= 'The monster is dodging. No damage has been scored.
'; + } + + if (Lib::user()->currentuberdamage != 0) { + $monsterdamage += (int) ceil($monsterdamage * (Lib::user()->currentuberdamage / 100)); + } + + Lib::user()->currentmonsterhp -= $monsterdamage; + $page['yourturn'] .= "You attack the monster for $monsterdamage damage.

"; + $page['monsterhp'] = "Monster's HP: ".Lib::user()->currentmonsterhp.'

'; + + // Check for monster defeat + if (Lib::user()->currentmonsterhp <= 0) { + Lib::user()->currentmonsterhp = 0; + Lib::user()->save(); + Lib::redirect('/victory'); + } + + // Monster's turn + $page['monsterturn'] = self::handleMonsterTurn($userrow, $monster); + } + + // Spell action + if (isset($_POST['spell'])) { + $pickedspell = $_POST['userspell']; + if ($pickedspell == 0) { + return 'You must select a spell first. Please go back and try again.'; + } + + $newspellrow = Lib::get_spell($pickedspell); + $spell = in_array($pickedspell, explode(',', Lib::user()->spells)); + + if (! $spell) { + return 'You have not yet learned this spell. Please go back and try again.'; + } + + if (Lib::user()->currentmp < $newspellrow['mp']) { + return 'You do not have enough Magic Points to cast this spell. Please go back and try again.'; + } + + // Spell type handling (similar to original public static function) + $page['yourturn'] = self::handleSpellCast($userrow, $newspellrow); + $page['monsterhp'] = "Monster's HP: ".Lib::user()->currentmonsterhp.'

'; + + // Check for monster defeat + if (Lib::user()->currentmonsterhp <= 0) { + Lib::user()->currentmonsterhp = 0; + Lib::user()->save(); + Lib::redirect('/victory'); + } + + // Monster's turn + $page['monsterturn'] = self::handleMonsterTurn($userrow, $monster); + } + + // Monster's turn if player lost first swing + if (! isset($_POST['run']) && ! isset($_POST['fight']) && ! isset($_POST['spell']) && $chancetoswingfirst == 0) { + $page['yourturn'] = 'The monster attacks before you are ready!

'; + $page['monsterhp'] = "Monster's HP: ".Lib::user()->currentmonsterhp.'

'; + $page['monsterturn'] = self::handleMonsterTurn($userrow, $monster); + } + + // Prepare command or death message + if ($playerisdead != 1) { + $page['command'] = <<
+
+

+ {$page['magiclist']} +

+
+ HTML; + + Lib::user()->currentfight += 1; + } else { + $page['command'] = <<You have died.

+ As a consequence, you've lost half of your gold. However, you have been given back a portion of your hit points to continue your journey.

+ You may now continue back to town, and we hope you fair better next time. + HTML; + } + + Lib::user()->save(); + + // Finalize page and display it + $page = Lib::render('fight', ['page' => $page]); + + return $page; + } + + public static function victory() + { + if (Lib::user()->currentmonsterhp != 0) { + Lib::redirect('/fight'); + } + if (Lib::user()->currentfight == 0) { + Lib::redirect('/'); + } + + $monsterrow = Lib::get_monster(Lib::user()->currentmonster); + + $min = (int) (($monsterrow['maxexp'] / 6) * 5); + $max = (int) $monsterrow['maxexp']; + $exp = mt_rand(min($min, $max), max($min, $max)); + if ($exp < 1) { + $exp = 1; + } + + if (Lib::user()->expbonus != 0) { + $exp += ceil((Lib::user()->expbonus / 100) * $exp); + } + + $min = (int) (($monsterrow['maxgold'] / 6) * 5); + $max = (int) $monsterrow['maxgold']; + + $gold = mt_rand(min($min, $max), max($min, $max)); + if ($gold < 1) { + $gold = 1; + } + + if (Lib::user()->goldbonus != 0) { + $gold += ceil((Lib::user()->goldbonus / 100) * $exp); + } + if (Lib::user()->experience + $exp < 16777215) { + $newexp = Lib::user()->experience += $exp; + $warnexp = ''; + } else { + $newexp = Lib::user()->experience; + $exp = 0; + $warnexp = 'You have maxed out your experience points.'; + } + if (Lib::user()->gold + $gold < 16777215) { + $newgold = Lib::user()->gold += $gold; + $warngold = ''; + } else { + $newgold = Lib::user()->gold; + $gold = 0; + $warngold = 'You have maxed out your gold.'; + } + + $levelrow = Lib::db()->query('SELECT * FROM levels WHERE id=? LIMIT 1;', [Lib::user()->level + 1])->fetchArray(SQLITE3_ASSOC); + + if (Lib::user()->level < 100) { + if ($newexp >= $levelrow[Lib::user()->charclass.'_exp']) { + Lib::user()->maxhp += $levelrow[Lib::user()->charclass.'_hp']; + Lib::user()->maxmp += $levelrow[Lib::user()->charclass.'_mp']; + Lib::user()->maxtp += $levelrow[Lib::user()->charclass.'_tp']; + Lib::user()->strength += $levelrow[Lib::user()->charclass.'_strength']; + Lib::user()->dexterity += $levelrow[Lib::user()->charclass.'_dexterity']; + Lib::user()->attackpower += $levelrow[Lib::user()->charclass.'_strength']; + Lib::user()->defensepower += $levelrow[Lib::user()->charclass.'_dexterity']; + Lib::user()->level += 1; + $newlevel = $levelrow['id']; + + if ($levelrow[Lib::user()->charclass.'_spells'] != 0) { + Lib::user()->spells .= ','.$levelrow[Lib::user()->charclass.'_spells']; + $spelltext = 'You have learned a new spell.
'; + } else { + $spelltext = ''; + $newspell = ''; + } + + $page = 'Congratulations. You have defeated the '.$monsterrow['name'].".
You gain $exp experience. $warnexp
You gain $gold gold. $warngold

You have gained a level!

You gain ".$levelrow[Lib::user()->charclass.'_hp'].' hit points.
You gain '.$levelrow[Lib::user()->charclass.'_mp'].' magic points.
You gain '.$levelrow[Lib::user()->charclass.'_tp'].' travel points.
You gain '.$levelrow[Lib::user()->charclass.'_strength'].' strength.
You gain '.$levelrow[Lib::user()->charclass.'_dexterity']." dexterity.
$spelltext
You can now continue exploring."; + $title = 'Courage and Wit have served thee well!'; + $dropcode = ''; + } else { + $page = 'Congratulations. You have defeated the '.$monsterrow['name'].".
You gain $exp experience. $warnexp
You gain $gold gold. $warngold

"; + + if (rand(1, 30) === 1) { + $droprow = Lib::db()->query('SELECT * FROM drops WHERE mlevel <= ? ORDER BY RANDOM() LIMIT 1;', [$monsterrow['level']])->fetchArray(SQLITE3_ASSOC); + $dropcode = "dropcode='".$droprow['id']."',"; + $page .= 'This monster has dropped an item. Click here to reveal and equip the item, or you may also move on and continue exploring.'; + } else { + $dropcode = ''; + $page .= 'You can now continue exploring.'; + } + + $title = 'Victory!'; + } + } + + Lib::user()->currentaction = 'Exploring'; + Lib::user()->currentfight = 0; + Lib::user()->currentuberdamage = 0; + Lib::user()->currentuberdefense = 0; + Lib::user()->currentmonstersleep = 0; + Lib::user()->currentmonsterimmune = 0; + Lib::user()->save(); + + Lib::page_title($title); + + return $page; + } + + public static function drop() + { + if (Lib::user()->dropcode == 0) { + Lib::redirect('/'); + } + + $droprow = Lib::get_drop(Lib::user()->dropcode); + + if (isset($_POST['submit'])) { + $slot = $_POST['slot']; + + if ($slot === 0) { + return 'Please go back and select an inventory slot to continue.'; + } + + $slotstr = 'slot'.$slot.'id'; + if (Lib::user()->$slotstr != 0) { + $slotrow = Lib::get_drop(Lib::user()->$slotstr); + + $old1 = explode(',', $slotrow['attribute1']); + if ($slotrow['attribute2'] != 'X') { + $old2 = explode(',', $slotrow['attribute2']); + } else { + $old2 = [0 => 'maxhp',1 => 0]; + } + $new1 = explode(',', $droprow['attribute1']); + if ($droprow['attribute2'] != 'X') { + $new2 = explode(',', $droprow['attribute2']); + } else { + $new2 = [0 => 'maxhp',1 => 0]; + } + + Lib::user()->$old1[0] -= $old1[1]; + Lib::user()->$old2[0] -= $old2[1]; + if ($old1[0] == 'strength') { + Lib::user()->attackpower -= $old1[1]; + } + if ($old1[0] == 'dexterity') { + Lib::user()->defensepower -= $old1[1]; + } + if ($old2[0] == 'strength') { + Lib::user()->attackpower -= $old2[1]; + } + if ($old2[0] == 'dexterity') { + Lib::user()->defensepower -= $old2[1]; + } + + Lib::user()->$new1[0] += $new1[1]; + Lib::user()->$new2[0] += $new2[1]; + if ($new1[0] == 'strength') { + Lib::user()->attackpower += $new1[1]; + } + if ($new1[0] == 'dexterity') { + Lib::user()->defensepower += $new1[1]; + } + if ($new2[0] == 'strength') { + Lib::user()->attackpower += $new2[1]; + } + if ($new2[0] == 'dexterity') { + Lib::user()->defensepower += $new2[1]; + } + + if (Lib::user()->currenthp > Lib::user()->maxhp) { + Lib::user()->currenthp = Lib::user()->maxhp; + } + if (Lib::user()->currentmp > Lib::user()->maxmp) { + Lib::user()->currentmp = Lib::user()->maxmp; + } + if (Lib::user()->currenttp > Lib::user()->maxtp) { + Lib::user()->currenttp = Lib::user()->maxtp; + } + + $slot_s = 'slot'.$_POST['slot']; + $slot_name = "{$slot_s}name"; + $slot_id = "{$slot_s}id"; + + Lib::user()->$slot_name = $droprow['name']; + Lib::user()->$slot_id = $droprow['id']; + } else { + $new1 = explode(',', $droprow['attribute1']); + if ($droprow['attribute2'] != 'X') { + $new2 = explode(',', $droprow['attribute2']); + } else { + $new2 = [0 => 'maxhp',1 => 0]; + } + + Lib::user()->$new1[0] += $new1[1]; + Lib::user()->$new2[0] += $new2[1]; + if ($new1[0] == 'strength') { + Lib::user()->attackpower += $new1[1]; + } + if ($new1[0] == 'dexterity') { + Lib::user()->defensepower += $new1[1]; + } + if ($new2[0] == 'strength') { + Lib::user()->attackpower += $new2[1]; + } + if ($new2[0] == 'dexterity') { + Lib::user()->defensepower += $new2[1]; + } + + $slot_s = 'slot'.$_POST['slot']; + $slot_name = "{$slot_s}name"; + $slot_id = "{$slot_s}id"; + + Lib::user()->$slot_name = $droprow['name']; + Lib::user()->$slot_id = $droprow['id']; + } + + Lib::user()->save(); + + return 'The item has been equipped. You can now continue exploring.'; + } + + $attributearray = ['maxhp' => 'Max HP', + 'maxmp' => 'Max MP', + 'maxtp' => 'Max TP', + 'defensepower' => 'Defense Power', + 'attackpower' => 'Attack Power', + 'strength' => 'Strength', + 'dexterity' => 'Dexterity', + 'expbonus' => 'Experience Bonus', + 'goldbonus' => 'Gold Bonus']; + + $page = 'The monster dropped the following item: '.$droprow['name'].'

'; + $page .= 'This item has the following attribute(s):
'; + + $attribute1 = explode(',', $droprow['attribute1']); + $page .= $attributearray[$attribute1[0]]; + if ($attribute1[1] > 0) { + $page .= ' +'.$attribute1[1].'
'; + } else { + $page .= $attribute1[1].'
'; + } + + if ($droprow['attribute2'] != 'X') { + $attribute2 = explode(',', $droprow['attribute2']); + $page .= $attributearray[$attribute2[0]]; + if ($attribute2[1] > 0) { + $page .= ' +'.$attribute2[1].'
'; + } else { + $page .= $attribute2[1].'
'; + } + } + + $page .= '
Select an inventory slot from the list below to equip this item. If the inventory slot is already full, the old item will be discarded.'; + $page .= '
'; + $page .= 'You may also choose to just continue exploring and give up this item.'; + + return $page; + } + + public static function dead() + { + return <<You have died.

+ As a consequence, you've lost half of your gold. However, you have been given back a portion of your hit points + to continue your journey.

+ You may now continue back to town, and we hope you fair better next time. + HTML; + } + + public static function handleMonsterTurn(&$userrow, $monsterrow) + { + $pagearray = ''; + if (Lib::user()->currentmonstersleep != 0) { + $chancetowake = rand(1, 15); + if ($chancetowake > Lib::user()->currentmonstersleep) { + Lib::user()->currentmonstersleep = 0; + $pagearray .= 'The monster has woken up.
'; + } else { + $pagearray .= 'The monster is still asleep.
'; + } + } + + if (Lib::user()->currentmonstersleep == 0) { + $tohit = (int) ceil(mt_rand((int) ($monsterrow['maxdam'] * 0.5), (int) $monsterrow['maxdam'])); + $toblock = (int) ceil(mt_rand((int) (Lib::user()->defensepower * 0.75), (int) Lib::user()->defensepower) / 4); + $tododge = rand(1, 150); + + if ($tododge <= sqrt(Lib::user()->dexterity)) { + $tohit = 0; + $pagearray .= "You dodge the monster's attack. No damage has been scored.
"; + $persondamage = 0; + } else { + $persondamage = max(1, $tohit - $toblock); + if (Lib::user()->currentuberdefense != 0) { + $persondamage -= (int) ceil($persondamage * (Lib::user()->currentuberdefense / 100)); + } + $persondamage = max(1, $persondamage); + } + + $pagearray .= "The monster attacks you for $persondamage damage.

"; + Lib::user()->currenthp -= $persondamage; + + if (Lib::user()->currenthp <= 0) { + $newgold = (int) ceil(Lib::user()->gold / 2); + $newhp = (int) ceil(Lib::user()->maxhp / 4); + Lib::db()->query("UPDATE users SET currenthp=?, currentaction='In Town', currentmonster=0, currentmonsterhp=0, currentmonstersleep=0, currentmonsterimmune=0, currentfight=0, latitude=0, longitude=0, gold=? WHERE id=?;", [ + $newhp, $newgold, $userrow['id'], + ]); + self::dead(); + } + } + + return $pagearray; + } + + public static function handleSpellCast(&$userrow, $newspellrow) + { + $pagearray = ''; + switch ($newspellrow['type']) { + case 1: // Heal spell + $newhp = min(Lib::user()->currenthp + $newspellrow['attribute'], Lib::user()->maxhp); + Lib::user()->currenthp = $newhp; + Lib::user()->currentmp -= $newspellrow['mp']; + $pagearray = "You have cast the {$newspellrow['name']} spell, and gained {$newspellrow['attribute']} Hit Points.

"; + break; + case 2: // Hurt spell + if (Lib::user()->currentmonsterimmune == 0) { + $monsterdamage = mt_rand((int) (($newspellrow['attribute'] / 6) * 5), $newspellrow['attribute']); + Lib::user()->currentmonsterhp -= $monsterdamage; + $pagearray = "You have cast the {$newspellrow['name']} spell for $monsterdamage damage.

"; + } else { + $pagearray = "You have cast the {$newspellrow['name']} spell, but the monster is immune to it.

"; + } + Lib::user()->currentmp -= $newspellrow['mp']; + break; + case 3: // Sleep spell + if (Lib::user()->currentmonsterimmune != 2) { + Lib::user()->currentmonstersleep = $newspellrow['attribute']; + $pagearray = "You have cast the {$newspellrow['name']} spell. The monster is asleep.

"; + } else { + $pagearray = "You have cast the {$newspellrow['name']} spell, but the monster is immune to it.

"; + } + Lib::user()->currentmp -= $newspellrow['mp']; + break; + case 4: // +Damage spell + Lib::user()->currentuberdamage = $newspellrow['attribute']; + Lib::user()->currentmp -= $newspellrow['mp']; + $pagearray = "You have cast the {$newspellrow['name']} spell, and will gain {$newspellrow['attribute']}% damage until the end of this fight.

"; + break; + case 5: // +Defense spell + Lib::user()->currentuberdefense = $newspellrow['attribute']; + Lib::user()->currentmp -= $newspellrow['mp']; + $pagearray = "You have cast the {$newspellrow['name']} spell, and will gain {$newspellrow['attribute']}% defense until the end of this fight.

"; + break; + } + + return $pagearray; + } +} diff --git a/src/DragonKnight/Actions/Forum.php b/src/DragonKnight/Actions/Forum.php new file mode 100644 index 0000000..e76f372 --- /dev/null +++ b/src/DragonKnight/Actions/Forum.php @@ -0,0 +1,137 @@ +get('/forum/thread/:x/:x', 'Forum\showthread'); + $r->form('/forum/new', 'Forum\newthread'); + $r->post('/forum/reply', 'Forum\reply'); + $r->get('/forum/list/:x', 'Forum\donothing'); + $r->get('/forum', 'Forum\donothing'); + + return $r; + } + + public static function donothing($start = 0) + { + $query = Lib::db()->query('SELECT * FROM forum WHERE parent=0 ORDER BY newpostdate DESC LIMIT 20 OFFSET ?;', [20 * $start]); + $page = << + + + + + + + + + + + + HTML; + + $hasRows = false; + + while ($row = $query->fetchArray(SQLITE3_ASSOC)) { + $hasRows = true; + $page .= << + + + + + HTML; + } + + if (! $hasRows) { + $page .= ''; + } + + $page .= '
+
New Thread
+
ThreadRepliesLast Post
{$row['title']}{$row['replies']}{$row['newpostdate']}
No threads in forum.
'; + + Lib::page_title('Forum'); + + return $page; + } + + public static function showthread($id, $start) + { + $posts = Lib::db()->query('SELECT * FROM forum WHERE id=? OR parent=? ORDER BY id LIMIT 15 OFFSET ?;', [$id, $id, $start * 15]); + $title = Lib::db()->query('SELECT title FROM forum WHERE id=? LIMIT 1;', [$id])->fetchArray(SQLITE3_ASSOC); + + $page = '
\n"; + while ($row = $posts->fetchArray(SQLITE3_ASSOC)) { + $page .= '\n"; + } + $page .= '
Forum :: '.$title['title']."
'.$row['author'].'

'.Lib::pretty_date($row['postdate']).'
'.nl2br($row['content'])."

'; + $page .= "
Reply To This Thread:

'; + + Lib::page_title('Forum: '.$title['title']); + + return $page; + } + + public static function reply() + { + $form = Lib::validate($_POST, [ + 'title' => [], + 'content' => [], + ]); + + if (! $form['valid']) { + exit(Lib::ul_from_validate_errors($form['errors'])); + } + + $form = $form['data']; + + Lib::db()->query('INSERT INTO forum (author, title, content, parent) VALUES (?, ?, ?, ?);', [ + Lib::user()->username, $form['title'], $form['content'], $form['parent'], + ]); + Lib::db()->query('UPDATE forum SET newpostdate=CURRENT_TIMESTAMP, replies=replies + 1 WHERE id=?;', [$form['parent']]); + + return self::showthread($form['parent'], 0); + } + + public static function newthread() + { + if (isset($_POST['submit'])) { + $form = Lib::validate($_POST, [ + 'title' => ['length:2-30'], + 'content' => [], + ]); + + if (! $form['valid']) { + exit(Lib::ul_from_validate_errors($form['errors'])); + } + + $form = $form['data']; + Lib::db()->query('INSERT INTO forum (author, title, content) VALUES (?, ?, ?);', [ + Lib::user()->username, $form['title'], $form['content'], + ]); + Lib::redirect('/forum/thread/'.Lib::db()->lastInsertRowID().'/0'); + } + + Lib::page_title('Form: New Thread'); + + return '
Make A New Post:

Title:


Message:


'; + } +} diff --git a/src/DragonKnight/Actions/Heal.php b/src/DragonKnight/Actions/Heal.php new file mode 100644 index 0000000..1e13ef3 --- /dev/null +++ b/src/DragonKnight/Actions/Heal.php @@ -0,0 +1,55 @@ +spells(); + $spell = Lib::get_spell($id); + $has_spell = false; + foreach ($user_spells as $us) { + if ($us['id'] === $id) { + $has_spell = true; + } + } + + if ($has_spell !== true) { + $page = 'You have not yet learned this spell. Please go back and try again.'; + } elseif ($spell['type'] !== 1) { + $page = 'This is not a healing spell. Please go back and try again.'; + } elseif (Lib::user()->currentmp < $spell['mp']) { + $page = 'You do not have enough Magic Points to cast this spell. Please go back and try again.'; + } elseif (Lib::user()->currentaction === 'Fighting') { + $page = 'You cannot use the Quick Spells list during a fight. Please go back and select the Healing Spell you wish to use from the Spells box on the main fighting screen to continue.'; + } elseif (Lib::user()->currenthp == Lib::user()->maxhp) { + $page = 'Your HP is already full. You don\'t need to use a Healing spell now.'; + } else { + $restored = Lib::user()->restore_hp($spell['attribute']); + Lib::user()->currentmp -= $spell['mp']; + Lib::user()->save(); + + $page = <<exploring. + HTML; + } + + Lib::page_title('Casting '.$spell['name']); + + return $page; + } +} diff --git a/src/DragonKnight/Actions/Help.php b/src/DragonKnight/Actions/Help.php new file mode 100644 index 0000000..b1fc11e --- /dev/null +++ b/src/DragonKnight/Actions/Help.php @@ -0,0 +1,517 @@ +get('/help', 'Help\main'); + $r->get('/help/items', 'Help\items'); + $r->get('/help/spells', 'Help\spells'); + $r->get('/help/monsters', 'Help\monsters'); + $r->get('/help/levels', 'Help\levels'); + + return $r; + } + + public static function main() + { + $page = <<Table of Contents + + +
+ +

Introduction

+ Firstly, I'd like to say thank you for playing my game. The Dragon Knight game engine is the result of several months of + planning, coding and testing. The original idea was to create a web-based tribute to the NES game, Dragon + Warrior. In its current iteration, only the underlying fighting system really resembles that game, as almost + everything else in DK has been made bigger and better. But you should still recognize bits and pieces as stemming + from Dragon Warrior and other RPGs of old.

+ This is the first game I've ever written, and it has definitely been a positive experience. It got difficult at + times, admittedly, but it was still a lot of fun to write, and even more fun to play. And I hope to use this + experience so that if I ever want to create another game it will be even better than this one.

+ If you are a site administrator, and would like to install a copy of DK on your own server, you may visit the + development site for Dragon Knight. This page + includes the downloadable game souce code, as well as some other resources that developers and administrators may + find valuable.

+ Once again, thanks for playing!

+ Jamin Seven
+ Dragon Knight creator
+ My Homepage
+ Dragon Knight Homepage

+ [ Top ] + +


+ +

Character Classes

+ There are three character classes in the game. The main differences between the classes are what spells you get + access to, the speed with which you level up, and the amount of HP/MP/strength/dexterity you gain per level. Below + is a basic outline of each of the character classes. For more detailed information about the characters, please + view the Levels table at the bottom of this page. Also, note that the outline below refers to the stock class setup + for the game. If your administrator has used his/her own class setup, this information may not be accurate.

+ {env('class_1_name')} + + {env('class_2_name')} + + {env('class_3_name')} + + [ Top ] + +


+ +

Playing The Game: In Town

+ When you begin a new game, the first thing you see is the Town screen. Towns serve four primary public static functions: healing, buying items, + buying maps, and displaying game information.

+ To heal yourself, click the "Rest at the Inn" link at the top of the town screen. Each town's Inn has a different price - some towns + are cheap, others are expensive. No matter what town you're in, the Inns always serve the same public static function: they restore your current + hit points, magic points, and travel points to their maximum amounts. Out in the field, you are free to use healing spells to restore + your hit points, but when you run low on magic points, the only way to restore them is at an Inn.

+ Buying weapons and armor is accomplished through the appropriately-named "Buy Weapons/Armor" link. Not every item is available in + every town, so in order to get the most powerful items, you'll need to explore some of the outer towns. Once you've clicked the link, + you are presented with a list of items available in this town's store. To the left of each item is an icon that represents its type: + weapon, armor or shield. The amount of attack/defense power, as well as the item's price, are displayed to the right of the item name. + You'll notice that some items have a red asterisk (*) next to their names. These are items that come + with special attributes that modify other parts of your character profile. See the Items & Drops table at the bottom of this page for + more information about special items.

+ Maps are the third public static function in towns. Buying a map to a town places the town in your Travel To box in the left status panel. Once + you've purchased a town's map, you can click its name from your Travel To box and you will jump to that town. Travelling this way + costs travel points, though, and you'll only be able to visit towns if you have enough travel points.

+ The final public static function in towns is displaying game information and statistics. This includes the latest news post made by the game + administrator, a list of players who have been online recently, and the Babble Box.

+ [ Top ] + +


+ +

Playing The Game: Exploring & Fighting

+ Once you're done in town, you are free to start exploring the world. Use the compass buttons on the left status panel to move around. + The game world is basically a big square, divided into four quadrants. Each quadrant is {{gamesize}} spaces + square. The first town is usually located at (0N,0E). Click the North button from the first town, and now you'll be at (1N,0E). + Likewise, if you now click the West button, you'll be at (1N,1W). Monster levels increase with every 5 spaces you move outward + from (0N,0E).

+ While you're exploring, you will occasionally run into monsters. As in pretty much any other RPG game, you and the monster take turns + hitting each other in an attempt to reduce each other's hit points to zero. Once you run into a monster, the Exploring screen changes + to the Fighting screen.

+ When a fight begins, you'll see the monster's name and hit points, and the game will ask you for your first command. You then get to + pick whether you want to fight, use a spell, or run away. Note, though, that sometimes the monster has the chance to hit you + first.

+ The Fight button is pretty straightforward: you attack the monster, and the amount of damage dealt is based on your attack power and + the monster's armor. On top of that, there are two other things that can happen: an Excellent Hit, which doubles your total attack + damage; and a monster dodge, which results in you doing no damage to the monster.

+ The Spell button allows you to pick an available spell and cast it. See the Spells list at the bottom of this page for more information + about spells.

+ Finally, there is the Run button, which lets you run away from a fight if the monster is too powerful. Be warned, though: it is + possible for the monster to block you from running and attack you. So if your hit points are low, you may fare better by staying + around monsters that you know can't do much damage to you.

+ Once you've had your turn, the monster also gets his turn. It is also possible for you to dodge the monster's attack and take no + damage.

+ The end result of a fight is either you or the monster being knocked down to zero hit points. If you win, the monster dies and will + give you a certain amount of experience and gold. There is also a chance that the monster will drop an item, which you can put into + one of the three inventory slots to give you extra points in your character profile. If you lose and die, half of your gold is taken + away - however, you are given back a few hit points to help you make it back to town (for example, if you don't have enough gold to + pay for an Inn, and need to kill a couple low-level monsters to get the money).

+ When the fight is over, you can continue exploring until you find another monster to beat into submission.

+ [ Top ] + +


+ +

Playing The Game: Status Panels

+ There are two status panels on the game screen: left and right.

+ The left panel inclues your current location and play status (In Town, Exploring, Fighting), compass buttons for movement, and the + Travel To list for jumping between towns. At the bottom of the left panel is also a list of game public static functions.

+ The right panel displays some character statistics, your inventory, and quick spells.

+ The Character section shows the most important character statistics. It also displays the status bars for your current hit points, + magic points and travel points. These status bars are colored either green, yellow or red depending on your current amount of each + stat. There is also a link to pop up your list of extended statistics, which shows more detailed character information.

+ The Fast Spells section lists any Heal spells you've learned. You may use these links any time you are in town or exploring to cast + the heal spell. These may not be used during fights, however - you have to use the Spells box on the fight screen for that. + [ Top ] + +


+ +

Spoilers: Items & Drops

+ Click here for the Items & Drops spoiler page.

+ [ Top ] + +


+ +

Spoilers: Monsters

+ Click here for the Monsters spoiler page.

+ [ Top ] + +


+ +

Spoilers: Spells

+ Click here for the Spells spoiler page.

+ [ Top ] + +


+ +

Spoilers: Levels

+ Click here for the Levels spoiler page.

+ [ Top ] + +


+ +

Credits

+ + Apologies and lots of happy naked love to anyone I forgot.

+ And of course, thanks to you for playing my game!

+ NINJA!

+ [ Top ] + +



+ + Please visit the following sites for more information:
+ Se7enet (Jamin's homepage)
+ Dragon Knight (official DK homepage)
+ Forums (official DK forums)

+ All original coding and graphics for the Dragon Knight game engine are © 2003-2005 by Jamin Seven.

+ [ Top ] + HTML; + + return self::display_help($page); + } + + public static function items() + { + $page = << +
Items
+ TypeNameCostAttributeSpecial + HTML; + + $items = Lib::db()->query('SELECT * FROM items ORDER BY id;'); + $item_types = [1 => ['weapon', 'Attack'], 2 => ['armor', 'Defense'], 3 => ['shield', 'Defense']]; + + while ($item = $items->fetchArray(SQLITE3_ASSOC)) { + $image = $item_types[$item['type']][0]; + $power = $item_types[$item['type']][1]; + + if ($item['special'] !== 'X') { + $special = explode(',', $item['special']); + $attr = Lib::special_to_string($special[0]); + $stat = (($special[1] > 0) ? '+' : '').$special[1]; + $bigspecial = "$attr $stat"; + } else { + $bigspecial = 'None'; + } + + $page .= "\"$image\"".$item['name'].''.$item['buycost'].' Gold'.$item['attribute']." $power Power$bigspecial\n"; + } + + $page .= << + +

+ + + + + HTML; + + $drops = Lib::db()->query('SELECT * FROM drops ORDER BY id;'); + + while ($drop = $drops->fetchArray(SQLITE3_ASSOC)) { + if ($drop['attribute1'] !== 'X') { + $special = explode(',', $drop['attribute1']); + $attr = Lib::special_to_string($special[0]); + $stat = (($special[1] > 0) ? '+' : '').$special[1]; + $bigspecial1 = "$attr $stat"; + } else { + $bigspecial1 = 'None'; + } + + if ($drop['attribute2'] !== 'X') { + $special = explode(',', $drop['attribute2']); + $attr = Lib::special_to_string($special[0]); + $stat = (($special[1] > 0) ? '+' : '').$special[1]; + $bigspecial2 = "$attr $stat"; + } else { + $bigspecial2 = 'None'; + } + + $page .= '\n"; + } + $page .= '
Drops
NameMonster LevelAttribute 1Attribute 2
'.$drop['name'].''.$drop['mlevel']."$bigspecial1$bigspecial2
'; + + return self::display_help($page); + } + + public static function spells() + { + $page = << +
Spells
+ NameCostTypeAttribute + HTML; + + $spells = Lib::db()->query('SELECT * FROM spells ORDER BY id;'); + $spell_types = ['None', 'Heal', 'Hurt', 'Sleep', '+Damage (%)', '+Defense (%)']; + while ($spell = $spells->fetchArray(SQLITE3_ASSOC)) { + $page .= <<{$spell['name']} + {$spell['mp']} + {$spell_types[$spell['type']]} + {$spell['attribute']} + HTML; + } + + $page .= << + + HTML; + + return self::display_help($page); + } + + public static function monsters() + { + $page = << +
Monsters
+ NameMax HPMax DamageArmorLevelMax Exp.Max GoldImmunity + HTML; + + $monsters = Lib::db()->query('SELECT * FROM monsters ORDER BY id;'); + $immunities = ['None', 'Hurt', 'Hurt & Sleep']; + + while ($m = $monsters->fetchArray(SQLITE3_ASSOC)) { + $immune = $immunities[$m['immune']] ?? 'Unknown'; + $page .= ''.$m['name'].''.$m['maxhp'].''.$m['maxdam'].''.$m['armor'].''.$m['level'].''.$m['maxexp'].''.$m['maxgold']."$immune\n"; + } + + return self::display_help($page.''); + } + + public static function levels() + { + $rows = []; + + $levels = Lib::db()->query('SELECT * FROM levels ORDER BY id;'); + while ($level = $levels->fetchArray(SQLITE3_ASSOC)) { + $class_data = [1 => [], 2 => [], 3 => []]; + + foreach ($level as $column => $value) { + if ($column === 'id') { + continue; + } + + $parts = explode('_', $column); + $class_number = (int) $parts[0]; + $attribute = $parts[1]; + + if (in_array($class_number, [1, 2, 3])) { + $class_data[$class_number][$level['id']][$attribute] = $value; + } + } + + $rows[$level['id']] = $class_data; + } + + $spells = []; + $spells_query = Lib::db()->query('SELECT * FROM spells ORDER BY id;'); + while ($spell = $spells_query->fetchArray(SQLITE3_ASSOC)) { + $spells[$spell['id']] = $spell; + } + + $page = << +
{{class1name}} Levels
+ LevelExp.HPMPTPStrengthDexteritySpell + HTML; + foreach ($rows as $level => $class_data) { + $level_data = $class_data[1][$level]; + + $spell_name = ''; + if (! empty($level_data['spells'])) { + $spell_name = $spells[$level_data['spells']]['name']; + } + + $page .= ''; + $page .= ''.$level.''; + $page .= ''.($level_data['exp'] ?? '').''; + $page .= ''.($level_data['hp'] ?? '').''; + $page .= ''.($level_data['mp'] ?? '').''; + $page .= ''.($level_data['tp'] ?? '').''; + $page .= ''.($level_data['strength'] ?? '').''; + $page .= ''.($level_data['dexterity'] ?? '').''; + $page .= ''.(! empty($spell_name) ? $spell_name : 'None').''; + $page .= ''; + } + + $page .= << + +

+ + + + + HTML; + + foreach ($rows as $level => $class_data) { + $level_data = $class_data[2][$level]; + + $spell_name = ''; + if (! empty($level_data['spells'])) { + $spell_name = $spells[$level_data['spells']]['name']; + } + + $page .= ''; + $page .= ''; + $page .= ''; + $page .= ''; + $page .= ''; + $page .= ''; + $page .= ''; + $page .= ''; + $page .= ''; + $page .= ''; + } + + $page .= << + +

+ +
{{class2name}} Levels
LevelExp.HPMPTPStrengthDexteritySpell
'.$level.''.($level_data['exp'] ?? '').''.($level_data['hp'] ?? '').''.($level_data['mp'] ?? '').''.($level_data['tp'] ?? '').''.($level_data['strength'] ?? '').''.($level_data['dexterity'] ?? '').''.(! empty($spell_name) ? $spell_name : 'None').'
+ + + HTML; + + foreach ($rows as $level => $class_data) { + $level_data = $class_data[3][$level]; + + $spell_name = ''; + if (! empty($level_data['spells'])) { + $spell_name = $spells[$level_data['spells']]['name']; + } + + $page .= ''; + $page .= ''; + $page .= ''; + $page .= ''; + $page .= ''; + $page .= ''; + $page .= ''; + $page .= ''; + $page .= ''; + $page .= ''; + } + + $page .= << + +
+ + Experience points listed are total values up until that point. All other values are just the new amount that you gain for each level. + HTML; + + return self::display_help($page); + } + + public static function display_help(string $content) + { + return Lib::render('layouts/help', [ + 'content' => $content, + 'version' => VERSION, + 'build' => BUILD, + ]); + } +} diff --git a/src/DragonKnight/Actions/Install.php b/src/DragonKnight/Actions/Install.php new file mode 100644 index 0000000..c62d9ed --- /dev/null +++ b/src/DragonKnight/Actions/Install.php @@ -0,0 +1,796 @@ +get('/install', 'Install\first'); + $r->get('/install/second', 'Install\second'); + $r->get('/install/third', 'Install\third'); + $r->post('/install/fourth', 'Install\fourth'); + $r->get('/install/fifth', 'Install\fifth'); + } + + return $r; + } + + /** + * First page - show warnings and gather info. + */ + public static function first() + { + return << + + Dragon Knight Installation + + + + Dragon Knight Installation: Page One

+ Installation for Dragon Knight is a simple two-step process: set up the database tables, then create the admin user. After that, you're done.

+ + + + HTML; + } + + /** + * Set up database tables. + */ + public static function second() + { + if (! is_dir($path = getcwd().'/db')) { + if (mkdir($path, 0777, true) === false) { + throw new \Exception('Failed to create database directory at '.$path.'. Please check permissions.'); + } + } + if (file_exists($path = getcwd().'/db/database.db')) { + if (unlink($path) === false) { + throw new \Exception('Failed to delete existing database file at '.$path.'. Please check permissions.'); + } + } + + $page = 'Dragon Knight InstallationDragon Knight Installation: Page Two

'; + + $query = Lib::db()->exec(<<exec(<<exec(<<exec(<<exec(<<exec(<<exec(<<exec(<<exec(<<exec(<<exec(<<exec(<<exec(<<exec("INSERT INTO news (content) VALUES ('This is the first news post. Please use the admin control panel to add another one and make this one go away.');"); + + $page .= self::table_status_msg($query === true, 'News', 'populate'); + + $query = Lib::db()->exec(<<exec(<<exec(<<exec(<<exec(<<Database setup complete in $time seconds.

Click here to continue with installation."; + } + + /** + * Gather user info for admin account. + */ + public static function third() + { + return << + + Dragon Knight Installation + + + Dragon Knight Installation: Page Three

+ Now you must create an administrator account so you can use the control panel. Fill out the form below to create your account. You will be able to customize the class names through the control panel once your admin account is created.

+
+
{{class3name}} Levels
LevelExp.HPMPTPStrengthDexteritySpell
'.$level.''.($level_data['exp'] ?? '').''.($level_data['hp'] ?? '').''.($level_data['mp'] ?? '').''.($level_data['tp'] ?? '').''.($level_data['strength'] ?? '').''.($level_data['dexterity'] ?? '').''.(! empty($spell_name) ? $spell_name : 'None').'
+ + + + + + + +
Username:


Password:
Verify Password:


Email Address:
Verify Email:


Character Class:
+ + + + HTML; + } + + /** + * Final page: insert new user row, congratulate the person on a job well done. + */ + public static function fourth(?array $post = null) + { + $post ??= $_POST; + $form = Lib::validate($post, [ + 'username' => ['length:3-18', 'alpha-spaces'], + 'email' => ['email'], + 'confirm_email' => ['confirm'], + 'password' => ['length:6-255'], + 'confirm_password' => ['confirm'], + ]); + + if (! $form['valid']) { + exit(Lib::ul_from_validate_errors($form['errors'])); + } + + $form = $form['data']; + if (Lib::db()->query( + "INSERT INTO users (username, password, email, verify, charclass, authlevel) VALUES (?, ?, ?, 'g2g', ?, 1)", + [$form['username'], password_hash($form['password'], PASSWORD_ARGON2ID), $form['email'], $form['charclass'] ?? null] + ) === false) { + exit('Failed to create user.'); + } + + file_put_contents(getcwd().'/.installed', date('Y-m-d H:i:s')); + + return << + + Dragon Knight Installation + + + Dragon Knight Installation: Page Four

+ Your admin account was created successfully. Installation is complete.

+ Be sure to delete install.php from your Dragon Knight directory for security purposes.

+ You are now ready to play the game. Note that you must log in through the public section before being allowed into the control panel. Once logged in, an "Admin" link will appear in the public static functions box of the left sidebar panel.

+ Thank you for using Dragon Knight!

-----

+ Optional: Dragon Knight is a free product, and does not require registration of any sort. However, there is an + optional "call home" public static function in the installer, which notifies the author of your game installation. The ONLY information + transmitted with this public static function is the URL to your game. This is included mainly to satisfy the author's curiosity about + how many copies of the game are being installed and used. If you choose to submit your URL to the author, please + click here. + + + HTML; + } + + /** + * Call Home public static function. + */ + public static function fifth() + { + /**if (mail('sky@sharkk.net', 'Dragon Knight Call Home', $_SERVER['SERVER_NAME'].$_SERVER['PHP_SELF']) !== true) { + exit('Dragon Knight was unable to send your URL. Please go back and try again, or just continue on to the game.'); + }*/ + + return << + + Dragon Knight Installation + + + Dragon Knight Installation: Page Five

+ Thank you for submitting your URL!

+ You are now ready to play the game. Note that you must log in through the public section before being allowed into the control panel. Once logged in, an "Admin" link will appear in the public static functions box of the left sidebar panel. + + + HTML; + } + + public static function table_status_msg(bool $condition, string $table_name, string $verb): string + { + $verb = match ($verb) { + 'create' => ['created', 'creating'], + 'populate' => ['populated', 'populating'] + }; + + if ($condition === false) { + return "Error {$verb[1]} $table_name table. (".Lib::db()->lastErrorMsg().')
'; + } + + return "$table_name table {$verb[0]}.
"; + } +} diff --git a/src/DragonKnight/Actions/Towns.php b/src/DragonKnight/Actions/Towns.php new file mode 100644 index 0000000..ffea7af --- /dev/null +++ b/src/DragonKnight/Actions/Towns.php @@ -0,0 +1,435 @@ +form('/inn', 'Towns\inn'); + $r->get('/shop', 'Towns\shop'); + $r->form('/buy/:id', 'Towns\buy'); + // $r->get('/sell', 'Towns\sell'); + $r->get('/maps', 'Towns\maps'); + $r->form('/maps/:id', 'Towns\buy_map'); + $r->get('/gotown/:id', 'Towns\travelto'); + + return $r; + } + + /** + * Spit out the main town page. + */ + public static function town() + { + $town = Lib::get_town_by_xy(Lib::user()->longitude, Lib::user()->latitude); + if ($town === false) { + exit('There is an error with your user account, or with the town data. Please try again.'); + } + + $page = ['news' => '', 'whos_online' => '']; + + // News box. Grab latest news entry and display it. Something a little more graceful coming soon maybe. + if (Lib::env('show_news')) { + $news = Lib::db()->query('SELECT * FROM news ORDER BY id DESC LIMIT 1;')->fetchArray(SQLITE3_ASSOC); + $news_date = Lib::pretty_date($news['postdate']); + $news_content = nl2br($news['content']); + $page['news'] = <<Latest News + $news_date
+ $news_content + HTML; + } + + // Who's Online. Currently just members. Guests maybe later. + if (Lib::env('show_online')) { + $onlinequery = Lib::db()->query(<<= datetime('now', '-600 seconds') + ORDER BY username; + SQL); + + $online_count = 0; + $online_rows = []; + + while ($onlinerow = $onlinequery->fetchArray(SQLITE3_ASSOC)) { + $online_count++; + $online_rows[] = "".$onlinerow['username'].''; + } + + $online_rows = implode(', ', $online_rows); + $page['whos_online'] = <<Who's Online + There are $online_count Lib::user(s) online within the last 10 minutes: $online_rows + HTML; + } + + Lib::page_title($town['name']); + + return Lib::render('towns', ['town' => $town, 'news' => $page['news'], 'whos_online' => $page['whos_online']]); + } + + /** + * Staying at the inn resets all expendable stats to their max values. + * GET/POST /inn. + */ + public static function inn() + { + $town = Lib::get_town_by_xy(Lib::user()->longitude, Lib::user()->latitude); + if ($town === false) { + exit('Cheat attempt detected.

Get a life, loser.'); + } + + if (Lib::user()->gold < $town['innprice']) { + $page = <<
+ You may return to town, or use the direction buttons on the left to start exploring. + HTML; + } elseif (Lib::is_post() && $_POST['rest']) { + Lib::user()->gold -= $town['innprice']; + Lib::user()->restore_points()->save(); + $page = <<
+ You may return to town, or use the direction buttons on the left to start exploring. + HTML; + } elseif (Lib::is_post() && ! $_POST['rest']) { + Lib::redirect('/'); + } else { + $page = <<
+ A night's sleep at this Inn will cost you {$town['innprice']} gold. Is that ok?

+
+ + +
+ HTML; + } + + Lib::page_title($town['name'].' Inn'); + + return $page; + } + + /** + * Displays a list of available items for purchase from the town the user is currently in. If the user is not in a town, + * redirects to home. + * GET /shop. + */ + public static function shop() + { + $town = Lib::get_town_by_xy(Lib::user()->longitude, Lib::user()->latitude); + if ($town === false) { + exit('Cheat attempt detected.

Get a life, loser.'); + } + + $page = <<
+ Click an item name to purchase it.

+ The following items are available at this town:

+ + HTML; + + $items = Lib::db()->query('SELECT * FROM items WHERE id IN ('.$town['itemslist'].');'); + while ($item = $items->fetchArray(SQLITE3_ASSOC)) { + $attrib = ($item['type'] == 1) ? 'Attack Power:' : 'Defense Power:'; + $page .= ''; + if (Lib::user()->weaponid === $item['id'] || Lib::user()->armorid === $item['id'] || Lib::user()->shieldid === $item['id']) { + $page .= <<{$item['name']} + + + HTML; + } else { + $specialdot = $item['special'] !== 'X' ? '*' : ''; + $page .= <<{$item['name']}$specialdot + + + HTML; + } + $page .= ''; + } + $page .= <<
+ If you've changed your mind, you may also return back to town. + HTML; + + Lib::page_title($town['name'].' Shop'); + + return $page; + } + + /** + * Confirm user's intent to purchase item. + */ + public static function buy(int $id) + { + $town = Lib::get_town_by_xy(Lib::user()->longitude, Lib::user()->latitude); + if ($town === false) { + Lib::redirect('/'); + } + if (! in_array($id, explode(',', $town['itemslist']))) { + Lib::redirect('/shop'); + } + $item = Lib::get_item($id); + $can_afford = Lib::user()->gold >= $item['buycost']; + + if (! $can_afford) { + $page = <<{$item['name']}.

+ You may return to town, shop, + or use the direction buttons on the left to start exploring. + HTML; + } elseif (Lib::is_post() && ! $_POST['buy']) { + Lib::redirect('/shop'); + } elseif (Lib::is_post() && $_POST['buy']) { + $type_mapping = [ + 1 => ['id' => 'weaponid', 'name' => 'weaponname', 'power' => 'attackpower'], + 2 => ['id' => 'armorid', 'name' => 'armorname', 'power' => 'defensepower'], + 3 => ['id' => 'shieldid', 'name' => 'shieldname', 'power' => 'defensepower'], + ]; + + if (! isset($type_mapping[$item['type']])) { // should never happen + return 'Error! Invalid item type...
'.var_dump($item); + } + + // Retrieve current equipped item or create a default + $current_equip_id = Lib::user()->{$type_mapping[$item['type']]['id']}; + if ($current_equip_id != 0) { + $item2 = Lib::get_item($current_equip_id); + } else { + $item2 = ['attribute' => 0, 'buycost' => 0, 'special' => 'X']; + } + + // Process special item effects + $specialFields = []; + $specialValues = []; + $powerAdjustments = 0; + + foreach ([$item, $item2] as $index => $process_item) { + if ($process_item['special'] != 'X') { + $special = explode(',', $process_item['special']); + $toChange = $special[0]; + $changeAmount = $index === 0 ? $special[1] : -$special[1]; + + Lib::user()->$toChange += $changeAmount; + $specialFields[] = "$toChange = ?"; + $specialValues[] = Lib::user()->$toChange; + + // Adjust attack or defense power + if ($toChange == 'strength' || $toChange == 'dexterity') { + $powerType = $toChange == 'strength' ? 'attackpower' : 'defensepower'; + $powerAdjustments += $changeAmount; + } + } + } + + // Determine power and type-specific updates + $currentType = $type_mapping[$item['type']]; + $powerField = $currentType['power']; + Lib::user()->$powerField += $item['attribute'] - $item2['attribute']; + + // Calculate new gold with trade-in value + Lib::user()->gold += ceil($item2['buycost'] / 2) - $item['buycost']; + + // Ensure current HP/MP/TP don't exceed max values + Lib::user()->currenthp = min(Lib::user()->currenthp, Lib::user()->maxhp); + Lib::user()->currentmp = min(Lib::user()->currentmp, Lib::user()->maxmp); + Lib::user()->currenttp = min(Lib::user()->currenttp, Lib::user()->maxtp); + + // Update item info in user + Lib::user()->{$type_mapping[$item['type']]['id']} = $item['id']; + Lib::user()->{$type_mapping[$item['type']]['name']} = $item['name']; + + Lib::user()->save(); + + $page = <<{$item['name']}.

+ You may return to town, shop, or use the direction buttons on the + left to start exploring. + HTML; + } else { + $type_to_row_mapping = [1 => 'weaponid', 2 => 'armorid', 3 => 'shieldid']; + $current_equipped_id = Lib::user()->{$type_to_row_mapping[$item['type']]} ?? 0; + + if ($current_equipped_id != 0) { + $item2 = Lib::get_item($current_equipped_id); + $sell_price = ceil($item2['buycost'] / 2); + $page = <<
+ + + + + HTML; + } else { + $page = <<
+ + + + + HTML; + } + } + + Lib::page_title('Buying '.$item['name']); + + return $page; + } + + /** + * List maps the user can buy. + */ + public static function maps() + { + $page = <<
+ Click a town name to purchase its map.

+
'; + $page .= match ($item['type']) { + 1 => 'weapon', + 2 => 'armor', + 3 => 'shield' + }; + $page .= '$attrib {$item['attribute']}Already purchased$attrib {$item['attribute']}Price: {$item['buycost']} gold
+ HTML; + + $mapped = explode(',', Lib::user()->towns); + $towns = Lib::db()->query('SELECT * FROM towns ORDER BY id;'); + while ($town = $towns->fetchArray(SQLITE3_ASSOC)) { + $latitude = ($town['latitude'] >= 0) ? $town['latitude'].'N,' : ($town['latitude'] * -1).'S,'; + $longitude = ($town['longitude'] >= 0) ? $town['longitude'].'E' : ($town['longitude'] * -1).'W'; + + if (in_array($town['id'], $mapped)) { + $page .= << + + + + + + HTML; + } else { + $page .= << + + + + + HTML; + } + } + + $page .= <<
+ If you've changed your mind, you may also return back to town. + HTML; + + Lib::page_title('Maps'); + + return $page; + } + + public static function buy_map(int $id): string + { + $town = Lib::get_town_by_id($id); + if ($town === false) { + Lib::redirect('/maps'); + } + + if (Lib::user()->gold < $town['mapprice']) { + $page = <<
+ You may return to town, store, or use the direction buttons on the left to start exploring. + HTML; + } elseif (Lib::is_post() && $_POST['buy']) { + Lib::user()->towns .= ",$id"; + Lib::user()->gold -= $town['mapprice']; + Lib::user()->save(); + + $page = <<
+ You may return to town, map shop, or use the direction buttons on the left to start exploring. + HTML; + } elseif (Lib::is_post() && ! $_POST['buy']) { + Lib::redirect('/maps'); + } else { + $page = <<{$town['name']} map for {$town['mapprice']} gold. Is that ok?

+ + + + + HTML; + } + + Lib::page_title('Buying '.$town['name'].' Map'); + + return $page; + } + + /** + * Send a user to a town from the Travel To menu. + */ + public static function travelto(int $id, bool $use_points = true): string + { + if (Lib::user()->currentaction == 'Fighting') { + Lib::redirect('/fight'); + } + + $town = Lib::get_town_by_id($id); + $cost = $use_points ? $town['travelpoints'] : 0; + $mapped = explode(',', Lib::user()->towns); + $travelled = false; + + if ($use_points && ! in_array($id, $mapped)) { + // trying to teleport to this town when it is not mapped + Lib::redirect('/'); + } elseif (Lib::user()->currenttp < $cost) { + $page = 'You do not have enough TP to travel here. Please go back and try again when you get more TP.'; + } elseif ((Lib::user()->latitude == $town['latitude']) && (Lib::user()->longitude == $town['longitude'])) { + if (! in_array($id, $mapped)) { + // add town to user's mapped if they travelled here + Lib::user()->towns .= ",$id"; + $travelled = true; + $page = <<{$town['name']}! It has been added to your mapped towns.

+ You may now enter this town. + HTML; + } else { + $page = 'You are already in this town. Click here to return.'; + } + } else { + Lib::user()->latitude = $town['latitude']; + Lib::user()->longitude = $town['longitude']; + Lib::user()->currenttp -= $cost; + $travelled = true; + $page = 'You have travelled to '.$town['name'].'. You may now enter this town.'; + } + + if ($travelled) { + Lib::user()->currentaction = 'In Town'; + Lib::user()->save(); + } + + Lib::page_title('Travelling to '.$town['name']); + + return $page; + } +} diff --git a/src/DragonKnight/Actions/Users.php b/src/DragonKnight/Actions/Users.php new file mode 100644 index 0000000..52b5aa1 --- /dev/null +++ b/src/DragonKnight/Actions/Users.php @@ -0,0 +1,260 @@ +form('/login', 'Users\login'); + $r->get('/logout', 'Users\logout'); + $r->form('/register', 'Users\register'); + $r->form('/lostpassword', 'Users\lostpassword'); + $r->post('/changepassword', 'Users\changepassword'); + $r->form('/verify', 'Users\verify'); + $r->form('/settings', 'Users\settings'); + + return $r; + } + + /** + * Displays the login page, and processes login requests. + */ + public static function login() + { + global $auth; + + if (Lib::is_post()) { + $form = Lib::validate($_POST, [ + 'username' => ['length:3-18', 'alpha-spaces'], + 'password' => ['length:6-255'], + ]); + + if (! $form['valid']) { + exit(Lib::ul_from_validate_errors($form['errors'])); + } + + $good = $auth->login($form['data']['username'], $form['data']['password']); + if (! $good) { + exit('Invalid username or password. Please go back and try again.'); + } + + Lib::redirect('/'); + } + + Lib::page_title('Login'); + + return Lib::render('login'); + } + + /** + * Delete the current cookie and redirect to home. + */ + public static function logout() + { + global $auth; + $auth->logout(); + Lib::redirect('/login'); + } + + /** + * Register a new account. + */ + public static function register() + { + if (isset($_POST['submit'])) { + $form = Lib::validate($_POST, [ + 'username' => ['length:3-18', 'alpha-spaces', 'unique:users,username'], + 'email' => ['email', 'unique:users,email'], + 'confirm_email' => ['confirm'], + 'password' => ['length:6-255'], + 'confirm_password' => ['confirm'], + 'charclass' => ['in:1,2,3'], + ]); + + if (! $form['valid']) { + $err = Lib::ul_from_validate_errors($form['errors']); + $page = "The following error(s) occurred when your account was being made:
$err
Please go back and try again."; + } else { + $form = $form['data']; + $password = password_hash($form['password'], PASSWORD_ARGON2ID); + $token = Lib::env('verify_email') ? Lib::token(8) : 'g2g'; + Lib::db()->query('INSERT INTO users (verify, username, password, email, charclass) VALUES (?, ?, ?, ?, ?)', [ + $token, $form['username'], $password, $form['email'], $form['charclass'], + ]); + + if (Lib::env('verify_email')) { + if (self::sendregmail($form['email'], $token)) { + $page = 'Your account was created successfully.

You should receive an Account Verification email shortly. You will need the verification code contained in that email before you are allowed to log in. Once you have received the email, please visit the Verification Page to enter your code and start playing.'; + } else { + $page = 'Your account was created successfully.

However, there was a problem sending your verification email. Please check with the game administrator to help resolve this problem.'; + } + } else { + $page = 'Your account was created succesfully.

You may now continue to the Login Page and continue playing '.Lib::env('game_name').'!'; + } + } + } else { + if (Lib::env('verify_email')) { + $verify_text = '
A verification code will be sent to the address above, and you will not be able to log in without first entering the code. Please be sure to enter your correct email address.'; + } else { + $verify_text = ''; + } + + $page = Lib::render('register', ['verify_text' => $verify_text]); + } + + Lib::page_title('Register'); + + return $page; + } + + public static function verify() + { + if (isset($_POST['submit'])) { + $u = trim($_POST['username'] ?? ''); + $e = trim($_POST['email'] ?? ''); + $t = trim($_POST['token'] ?? ''); + + $query = Lib::db()->query('SELECT id FROM users WHERE username=? AND email=? AND verify=? LIMIT 1;', [$u, $e, $t]); + if ($query === false) { + exit('Verification failed. Go back, double-check your details, and try again.'); + } + + Lib::db()->query("UPDATE users SET verify='g2g' WHERE username=?;", [$u]); + + return 'Your account was verified successfully.

You may now continue to the Login Page and start playing the game.

Thanks for playing!'; + } + + return Lib::render('verify'); + } + + public static function lostpassword() + { + if (isset($_POST['submit'])) { + $e = trim($_POST['email'] ?? ''); + + if (! Lib::db()->exists('users', 'email', $e)) { + exit('No account with that email address.'); + } + + $newpass = Lib::token(16); + $hashed = password_hash($newpass, PASSWORD_ARGON2ID); + + Lib::db()->query('UPDATE users SET password=? WHERE email=?;', [$hashed, $e]); + + if (self::sendpassemail($e, $newpass)) { + return 'Your new password was emailed to the address you provided.

Once you receive it, you may Log In and continue playing.

Thank you.'; + } + + return 'There was an error sending your new password.

Please check with the game administrator for more information.

We apologize for the inconvience.'; + } + + return Lib::render('lostpassword'); + } + + public static function changepassword() + { + global $auth; + + if (isset($_POST['submit'])) { + $u = trim($_POST['username'] ?? ''); + $p = $_POST['password'] ?? ''; + $np = $_POST['new_password'] ?? ''; + $np2 = $_POST['new_password2'] ?? ''; + + $user = Lib::db()->query('SELECT password FROM users WHERE username=? LIMIT 1;', [$u]); + $user = $user->fetchArray(SQLITE3_ASSOC); + if ($user === false) { + exit('No account with that username.'); + } + + if (! password_verify($p, $user['password'])) { + exit('The old password you provided was incorrect.'); + } + + if (empty($np) || strlen($np) < 6) { + $errors[] = 'New password is required and must be at least 6 characters long.'; + } + + if ($np2 !== $np) { + $errors[] = 'Verify New Password must match.'; + } + + $realnewpass = password_hash($np, PASSWORD_ARGON2ID); + Lib::db()->query('UPDATE users SET password=? WHERE username=?;', [$realnewpass, $u]); + + $auth->logout(); + + return 'Your password was changed successfully.

You have been logged out of the game to avoid errors.

Please log back in to continue playing.'; + } + } + + public static function settings() + { + if (Lib::is_post()) { + $form = Lib::validate($_POST, [ + 'game_skin' => ['in:0,1'], + ]); + if (! $form['valid']) { + exit(Lib::ul_from_validate_errors($form['errors'])); + } + $form = $form['data']; + + Lib::user()->game_skin = $form['game_skin']; + Lib::user()->save(); + + $alert = '
Settings updated
'; + + return $alert.Lib::render('settings'); + } + + return Lib::render('settings'); + } + + public static function sendpassemail($emailaddress, $password) + { + $email = << 2592000, // 30 days - 'path' => '/', - 'secure' => true, - 'httponly' => true, - 'samesite' => 'Strict' - ]); + session_set_cookie_params([ + 'lifetime' => 2592000, // 30 days + 'path' => '/', + 'secure' => true, + 'httponly' => true, + 'samesite' => 'Strict', + ]); - session_start(); + session_start(); - $this->validate(); - } + $this->validate(); + } - private function validate(): void - { - // Check for IP address change - if (!isset($_SESSION['ip_address'])) { + private function validate(): void + { + // Check for IP address change + if (! isset($_SESSION['ip_address'])) { $_SESSION['ip_address'] = $_SERVER['REMOTE_ADDR']; } elseif ($_SESSION['ip_address'] !== $_SERVER['REMOTE_ADDR']) { $this->destroy(); // Possible hijacking @@ -41,7 +49,7 @@ class Auth } // Check for User-Agent change - if (!isset($_SESSION['user_agent'])) { + if (! isset($_SESSION['user_agent'])) { $_SESSION['user_agent'] = $_SERVER['HTTP_USER_AGENT']; } elseif ($_SESSION['user_agent'] !== $_SERVER['HTTP_USER_AGENT']) { $this->destroy(); // Possible hijacking @@ -49,17 +57,19 @@ class Auth } // Regenerate session ID periodically for security - if (!isset($_SESSION['last_regeneration'])) { + if (! isset($_SESSION['last_regeneration'])) { $_SESSION['last_regeneration'] = time(); } elseif (time() - $_SESSION['last_regeneration'] > 300) { // Every 5 minutes $this->regenerate(); } - } + } - public function login(string $username, string $password): bool + public function login(string $username, string $password): bool { - $user = get_user($username); - if ($user === false) return false; + $user = Lib::get_user($username); + if ($user === false) { + return false; + } if (password_verify($password, $user['password'])) { $_SESSION['authenticated'] = true; @@ -74,31 +84,31 @@ class Auth return false; } - public function good(): bool + public function good(): bool { return isset($_SESSION['authenticated']) && $_SESSION['authenticated'] === true; } - public function logout(): void + public function logout(): void { $this->destroy(); } - private function regenerate(): void + private function regenerate(): void { session_regenerate_id(true); $_SESSION['last_regeneration'] = time(); } - public function destroy(): void - { + public function destroy(): void + { $_SESSION = []; - if (ini_get("session.use_cookies")) { + if (ini_get('session.use_cookies')) { $params = session_get_cookie_params(); setcookie(session_name(), '', time() - 42000, $params['path'], $params['domain'], $params['secure'], $params['httponly']); } session_destroy(); - } + } } diff --git a/src/DragonKnight/Bootstrap.php b/src/DragonKnight/Bootstrap.php new file mode 100644 index 0000000..72b9f81 --- /dev/null +++ b/src/DragonKnight/Bootstrap.php @@ -0,0 +1,76 @@ +good()) { + // Block user if he/she has been banned. + if (Lib::user()->authlevel === 2) { + exit('Your account has been banned.'); + } + + // Force verify if the user isn't verified yet. + if (Lib::env('verify_email') && Lib::user()->verify !== 'g2g' && ! in_array($uri[0], ['verify', 'logout'])) { + Lib::redirect('/verify'); + } + + // Ensure the user can't use the admin panel. + if (Lib::user()->authlevel !== 1 && $uri[0] === 'admin') { + Lib::redirect('/'); + } + + // Update default page layout based on root endpoint + Lib::page_layout('layouts/primary'); + if ($uri[0] === 'admin') { + Lib::page_layout('layouts/admin'); + } + if ($uri[0] === 'help') { + Lib::page_layout('layouts/help'); + } + + Lib::user()->update_online_time(); + } else { + $auth->logout(); + Lib::redirect('/login'); + } +} diff --git a/src/database.php b/src/DragonKnight/Database.php similarity index 55% rename from src/database.php rename to src/DragonKnight/Database.php index d00e110..ac0e782 100644 --- a/src/database.php +++ b/src/DragonKnight/Database.php @@ -1,5 +1,21 @@ prepare($query); - foreach ($params ?? [] as $k => $v) $stmt->bindValue($p ? $k + 1 : $k, $v, $this->getSQLiteType($v)); - } catch (Exception $e) { - exit("Failed to prepare query ($query): ".$this->lastErrorMsg()); - } + try { + $stmt = $this->prepare($query); + foreach ($params ?? [] as $k => $v) { + $stmt->bindValue($p ? $k + 1 : $k, $v, $this->getSQLiteType($v)); + } + } catch (\Exception $e) { + exit("Failed to prepare query ($query): ".$this->lastErrorMsg()); + } $start = microtime(true); - $error = ''; - $r = $stmt->execute(); + $error = ''; + $r = $stmt->execute(); $this->log($query, microtime(true) - $start, $error); return $r; } - /** - * Perform a result-less query on the database. - */ + /** + * Perform a result-less query on the database. + */ public function exec(string $query): bool { $start = microtime(true); $r = parent::exec($query); $this->log($query, microtime(true) - $start); + return $r; } - /** - * Determines whether a given value exists in a given column in a given table. Can optionally make it - * case-sensitive! - */ + /** + * Determines whether a given value exists in a given column in a given table. Can optionally make it + * case-sensitive! + */ public function exists(string $table, string $column, mixed $value, bool $case_insensitive = true): bool { if ($case_insensitive) { @@ -68,12 +87,13 @@ class Database extends SQLite3 } $result = $this->query($query, [':v' => $value]); + return $result->fetchArray(SQLITE3_NUM) !== false; } - /** - * Log the query, including the time it took. Increment the query counter. - */ + /** + * Log the query, including the time it took. Increment the query counter. + */ private function log(string $query, float $time_taken, string $error = ''): void { $this->count++; @@ -81,16 +101,16 @@ class Database extends SQLite3 $this->log[] = [$query, $time_taken, $error]; } - /** - * Return the correct SQLite3 type for the given value. - */ + /** + * Return the correct SQLite3 type for the given value. + */ private function getSQLiteType(mixed $value): int { return match (true) { - is_int($value) => SQLITE3_INTEGER, + is_int($value) => SQLITE3_INTEGER, is_float($value) => SQLITE3_FLOAT, - is_null($value) => SQLITE3_NULL, - default => SQLITE3_TEXT + is_null($value) => SQLITE3_NULL, + default => SQLITE3_TEXT }; } } diff --git a/src/DragonKnight/Lib.php b/src/DragonKnight/Lib.php new file mode 100644 index 0000000..0d63889 --- /dev/null +++ b/src/DragonKnight/Lib.php @@ -0,0 +1,731 @@ +currentaction === 'In Town') { + $page = Towns::town(); + } elseif (self::user()->currentaction === 'Exploring') { + $page = Explore::explore(); + } elseif (self::user()->currentaction === 'Fighting') { + self::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 😅. + */ + public static function show_map() + { + $pos = sprintf( + '
', + round(258 + Lib::user()->longitude * (500 / 500) - 3), + round(258 - Lib::user()->latitude * (500 / 500) - 3) + ); + + echo Lib::render('layouts/minimal', [ + 'content' => 'Map'.$pos, + 'title' => 'Map', + ]); + } + + /** + * Show a character's info. Defaults to the currently logged in user. + */ + public static function show_character_info(int $id = 0): string + { + $user = $id !== 0 ? User::find($id) : Lib::user(); + if ($user === false) { + exit('Failed to show info for user ID '.$id); + } + + $level = Lib::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'].'
'; + } + } + + $showchar = Lib::render('show_char', [ + 'char' => $user, + 'level' => $level, + 'magic_list' => $magic_list, + ]); + + return Lib::render('layouts/minimal', ['content' => $showchar, 'title' => $user->username.' Information']); + } + + /** + * Handle a POST request to send a new babblebox message. + */ + public static function babblebox() + { + if (Lib::is_post()) { + $content = trim($_POST['babble']); + if (! empty($content)) { + Lib::db()->query( + 'INSERT INTO babble (posttime, author, babble) VALUES (CURRENT_TIMESTAMP, ?, ?);', + [Lib::user()->username, $content] + ); + } + + return self::babblebox_messages(); + } + } + + /** + * The handler that is polled by HTMX for new babblebox messages. + */ + public static function babblebox_messages(): string + { + if (Lib::user() === false) { + return ''; + } + + $query = Lib::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 .= '
['.$row['author'].'] '.Lib::make_safe($row['babble']).'
'; + } + if (! $has_chats) { + $messages = 'There are no messages. :('; + } + + return $messages; + } + + /** + * Open or get SQLite database connection. + */ + public static 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. + */ + public static function redirect(string $location): void + { + if (self::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. + */ + public static 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. + */ + public static 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. + */ + public static 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. + */ + public static function make_safe(string $content): string + { + return htmlentities($content, ENT_QUOTES, 'UTF-8'); + } + + /** + * Finalize admin page and output to browser. + */ + public static function display_admin($content, $title) + { + echo self::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). + */ + public static function game_skin(): int + { + return self::user() !== false ? self::user()->game_skin : 0; + } + + /** + * Get a town's data by it's coordinates. + */ + public static function get_town_by_xy(int $x, int $y): array|false + { + $cache_tag = "town-$x-$y"; + + if (! isset($GLOBALS['cache'][$cache_tag])) { + $query = self::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. + */ + public static function get_town_by_id(int $id): array|false + { + $query = self::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. + */ + public static function get_user(int|string $id, string $data = '*'): array|false + { + $query = self::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. + */ + public static function get_item(int $id): array|false + { + $query = self::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. + */ + public static function get_drop(int $id): array|false + { + $query = self::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. + */ + public static function get_spell(int $id): array|false + { + $query = self::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. + */ + public static function get_monster(int $id): array|false + { + $query = self::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. + */ + public static 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. + */ + public static 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'] + */ + public static 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 (self::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. + */ + public static function ul_from_validate_errors(array $errors): string + { + $string = ''; + } + + /** + * Load the environment variables from the .env file. + */ + public static 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. + */ + public static 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. + */ + public static 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 = self::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; + } + + public static 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; + } + + public static function create_stat_table(): string + { + $stat_table = '
'. + '
'. + '
'.self::generate_stat_bar((int) self::user()->currenthp, (int) self::user()->maxhp).'
HP
'. + '
'.self::generate_stat_bar((int) self::user()->currentmp, (int) self::user()->maxmp).'
MP
'. + '
'.self::generate_stat_bar((int) self::user()->currenttp, (int) self::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. + */ + public static 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. + */ + public static 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. + */ + public static function is_post(): bool + { + return $_SERVER['REQUEST_METHOD'] === 'POST'; + } + + /** + * Get the current page title per updates. Optionally set a new title. + */ + public static function page_title(string $new_title = ''): string + { + if ($new_title) { + return $GLOBALS['state']['new-page-title'] = $new_title; + } + + return $GLOBALS['state']['new-page-title'] ?? self::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. + */ + public static function render_response(array $uri, string $content): string + { + if ($uri[0] === 'babblebox') { + return $content; + } + + if (self::is_htmx()) { + header('HX-Push-Url: '.$_SERVER['REQUEST_URI']); + + $content .= ''.self::page_title().''; + + $content .= Render::debug_db_info(); + + if (self::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, self::page_layout()); + } + + /** + * Get/set page layout through GLOBALS state. + */ + public static function page_layout(string $layout = ''): string + { + if ($layout === '') { + return $GLOBALS['state']['page-layout'] ?? 'layouts/primary'; + } + + return $GLOBALS['state']['page-layout'] = $layout; + } +} diff --git a/src/DragonKnight/Mail.php b/src/DragonKnight/Mail.php new file mode 100644 index 0000000..a65c754 --- /dev/null +++ b/src/DragonKnight/Mail.php @@ -0,0 +1,132 @@ + Lib::env('admin_email', 'noreply@'.$_SERVER['SERVER_NAME']), + 'log_path' => getcwd().'/logs/email.log', + 'method' => 'smtp', // 'smtp' or 'log' + 'smtp_host' => Lib::env('smtp_host', 'localhost'), + 'smtp_port' => Lib::env('smtp_port', 25), + 'smtp_username' => Lib::env('smtp_username', null), + 'smtp_password' => Lib::env('smtp_password', null), + 'smtp_encryption' => Lib::env('smtp_encryption', null), + ], $options); + + // Always send to log during debug + if (Lib::env('debug', false)) { + $config['method'] = 'log'; + } + + // Validate input + if (empty($to) || empty($subject) || empty($message)) { + error_log('Email sending failed: Missing required parameters'); + + return false; + } + + // Prepare email headers + $headers = [ + 'From: '.$config['from'], + 'X-Mailer: PHP/'.phpversion(), + ]; + + // Choose sending method + switch ($config['method']) { + case 'log': + // Log email details to file + $logMessage = sprintf( + "[%s] To: %s, Subject: %s, Message:\n\n %s\n\n\n\n", + date('Y-m-d H:i:s'), + $to, + $subject, + $message + ); + + // Attempt to log to file + if (file_put_contents($config['log_path'], $logMessage, FILE_APPEND) === false) { + error_log('Failed to write to log file: '.$config['log_path']); + + return false; + } + + return true; + + case 'smtp': + default: + // Attempt to send via SMTP + try { + // Prepare SMTP connection + $smtpConfig = [ + 'host' => $config['smtp_host'], + 'port' => $config['smtp_port'], + 'username' => $config['smtp_username'], + 'password' => $config['smtp_password'], + 'encryption' => $config['smtp_encryption'], + ]; + + // Send email using PHP's mail public static function (basic SMTP) + $result = mail( + $to, + $subject, + $message, + implode("\r\n", $headers) + ); + + if (! $result) { + error_log('SMTP email sending failed'); + + return false; + } + + return true; + } catch (\Exception $e) { + error_log('Email sending error: '.$e->getMessage()); + + return false; + } + } + } + + // Example usage: + // Send via SMTP + // send_email('recipient@example.com', 'Test Subject', 'Email body text'); + + // Send via log + // send_email('recipient@example.com', 'Test Subject', 'Email body text', ['method' => 'log']); + + // Customize SMTP settings + // send_email('recipient@example.com', 'Test Subject', 'Email body text', [ + // 'method' => 'smtp', + // 'smtp_host' => 'smtp.yourserver.com', + // 'smtp_port' => 587, + // 'smtp_username' => 'your_username', + // 'smtp_password' => 'your_password', + // 'smtp_encryption' => 'tls' + // ]); +} diff --git a/src/DragonKnight/Math.php b/src/DragonKnight/Math.php new file mode 100644 index 0000000..8b69838 --- /dev/null +++ b/src/DragonKnight/Math.php @@ -0,0 +1,120 @@ + 5) { + throw new \InvalidArgumentException('Growth rate must be between 0 and 5'); + } + + return match ($growth_rate) { + 0 => Math::calculate_erratic_exp($level), + 1 => (4 * pow($level, 3)) / 5, + 2 => pow($level, 3), + 3 => ((6 * pow($level, 3)) / 5) - (15 * pow($level, 2)) + (100 * $level) - 140, + 4 => (5 * pow($level, 3)) / 4, + 5 => Math::calculate_fluctuating_exp($level), + }; + } + + /** + * Calculate the ***total*** EXP for a given level in the Erratic formula. + */ + public static function calculate_erratic_exp(int $level): int + { + if ($level <= 50) { + return (pow($level, 3) * (100 - $level)) / 50; + } elseif ($level <= 68) { + return (pow($level, 3) * (150 - $level)) / 100; + } elseif ($level <= 98) { + return (pow($level, 3) * ((1911 - (10 * $level)) / 3)) / 500; + } + + return (pow($level, 3) * (160 - $level)) / 100; + } + + /** + * Calculate the ***total*** EXP for a given level in the Fluctuating formula. + */ + public static function calculate_fluctuating_exp(int $level): int + { + if ($level <= 15) { + return pow($level, 3) * ((((($level + 1) / 3) + 24) / 50)); + } elseif ($level <= 36) { + return pow($level, 3) * (($level + 14) / 50); + } + + return pow($level, 3) * ((($level / 2) + 32) / 50); + } + + /** + * Calculate a points total from a base. Modes: 1 (weak), 2 (normal), 3 (strong). + */ + public static function calculate_points(int $base_points, int $level, int $mode = 2): int + { + if ($level < 1) { + throw new \InvalidArgumentException('Level must be 1 or greater'); + } + + $growth_multiplier = match ($mode) { + 1 => 0.15, + 2 => 0.3, + 3 => 0.6, + default => throw new \InvalidArgumentException('Invalid mode. Use 1 (weak), 2 (normal), or 3 (strong)') + }; + + return (int) floor((2 * $base_points * $level * $growth_multiplier) / 100) + $level + 10; + } + + /** + * Calculate a stat total from a base. Modes: 1 (weak), 2 (normal), 3 (strong). + */ + public static function calculate_stat(int $base_stat, int $level, int $mode = 2): int + { + if ($level < 1) { + throw new \InvalidArgumentException('Level must be 1 or greater'); + } + + $growth_multiplier = match ($mode) { + 1 => 0.15, + 2 => 0.3, + 3 => 0.6, + default => throw new \InvalidArgumentException('Invalid mode. Use 1 (weak), 2 (normal), or 3 (strong)') + }; + + return (int) floor((2 * $base_stat * $level * $growth_multiplier) / 100) + 5; + } +} diff --git a/src/DragonKnight/Models/Model.php b/src/DragonKnight/Models/Model.php new file mode 100644 index 0000000..cc02788 --- /dev/null +++ b/src/DragonKnight/Models/Model.php @@ -0,0 +1,66 @@ +original_data = $data; + $this->changes = []; + } + + public function __get(string $key): mixed + { + return array_key_exists($key, $this->changes) ? $this->changes[$key] : $this->original_data[$key] ?? false; + } + + public function __set(string $key, mixed $value): void + { + if (array_key_exists($key, $this->original_data)) { + if ($value !== $this->original_data[$key]) { + $this->changes[$key] = $value; + } + } else { + throw new \InvalidArgumentException("Attempted to write to $key, which doesn't exist in the data for this model."); + } + } + + public function save(): bool + { + if (empty($this->changes)) { + return true; + } + + $placeholders = []; + $values = []; + foreach ($this->changes as $key => $value) { + $placeholders[] = "$key=?"; + $values[] = $value; + } + + $values[] = $this->id; + $query = 'UPDATE '.$this->table_name.' SET '.implode(', ', $placeholders).' WHERE id = ?;'; + + $result = Lib::db()->query($query, $values); + + return $result === false ? false : true; + } +} diff --git a/src/DragonKnight/Models/User.php b/src/DragonKnight/Models/User.php new file mode 100644 index 0000000..f43a3f2 --- /dev/null +++ b/src/DragonKnight/Models/User.php @@ -0,0 +1,116 @@ +query( + 'SELECT * FROM users WHERE id=? OR username=? COLLATE NOCASE OR email=? COLLATE NOCASE LIMIT 1;', + [$id, $id, $id] + ); + if ($query === false) { + return false; + } + $data = $query->fetchArray(SQLITE3_ASSOC); + if ($data === false) { + return false; + } + + return new User($data); + } + + /** + * Return a list of spells from this user's spell list. + */ + public function spells(): array|false + { + return Lib::get_spells_from_list($this->spells); + } + + /** + * Restore all HP, MP, and TP values to their max. + */ + public function restore_points(): User + { + $this->currenthp = $this->maxhp; + $this->currentmp = $this->maxmp; + $this->currenttp = $this->maxtp; + + return $this; + } + + /** + * Sends a manual update to online time for this user. + */ + public function update_online_time(): void + { + if ($this->onlinetime && strtotime($this->onlinetime) > strtotime('-9 minutes')) { + return; + } + Lib::db()->query('UPDATE users SET onlinetime=CURRENT_TIMESTAMP WHERE id=?;', [$this->id]); + } + + /** + * Heal HP by a given amount. Caps to max HP. Returns number of points restored. + */ + public function restore_hp(int $amount): int + { + $initial_hp = $this->currenthp; + $this->currenthp += $amount; + if ($this->currenthp > $this->maxhp) { + $this->currenthp = $this->maxhp; + } + + return $this->currenthp - $initial_hp; + } + + /** + * Save works just as it does on the Model class. In our case, though, user state changing may necessitate + * OOB swaps for parts of the UI that have user data displayed. Left and right nav, for example. In these cases, + * we set a flag in GLOBALS state to signify this. + */ + public function save(): bool + { + if (empty($this->changes)) { + return true; + } + + $placeholders = []; + $values = []; + foreach ($this->changes as $key => $value) { + $placeholders[] = "$key=?"; + $values[] = $value; + } + + $values[] = $this->id; + $query = 'UPDATE '.$this->table_name.' SET '.implode(', ', $placeholders).' WHERE id = ?;'; + + $result = Lib::db()->query($query, $values); + if ($result === false) { + return false; + } + $GLOBALS['state']['user-state-changed'] = true; + + return true; + } +} diff --git a/src/DragonKnight/Render.php b/src/DragonKnight/Render.php new file mode 100644 index 0000000..4e08d91 --- /dev/null +++ b/src/DragonKnight/Render.php @@ -0,0 +1,91 @@ + $content]); + } + + public static function debug_db_info(): string + { + $total_time = round(microtime(true) - START, 4); + $htmx = Lib::is_htmx() ? ' (htmx)' : ''; + + return '
'.$total_time.' Seconds, '.Lib::db()->count.' Queries'.$htmx.'
'; + } + + public static function right_nav(): string + { + if (Lib::user() === false) { + return ''; + } + + // Flashy numbers if they're low + $hp = (Lib::user()->currenthp <= (Lib::user()->maxhp / 5)) ? '*'.Lib::user()->currenthp.'*' : Lib::user()->currenthp; + $mp = (Lib::user()->currentmp <= (Lib::user()->maxmp / 5)) ? '*'.Lib::user()->currentmp.'*' : Lib::user()->currentmp; + + $template = Lib::render('right_nav', ['hp' => $hp, 'mp' => $mp]); + if (Lib::is_htmx()) { + $template = ''; + } + + return $template; + } + + public static function left_nav(): string + { + if (Lib::user() === false) { + return ''; + } + + $template = Lib::render('left_nav'); + if (Lib::is_htmx()) { + $template = '
'.$template.'
'; + } + + return $template; + } + + public static function babblebox(): string + { + return Lib::render('babblebox', ['messages' => babblebox_messages()]); + } + + public static function debug_query_log(): string + { + $html = '
';
+        foreach (Lib::db()->log as $record) {
+            $query_string = str_replace(["\r\n", "\n", "\r"], ' ', $record[0]);
+            $error_string = ! empty($record[2]) ? '// '.$record[2] : '';
+            $html .= '
['.round($record[1], 2)."s] {$query_string}{$error_string}
"; + } + + return $html.'
'; + } +} diff --git a/src/DragonKnight/Router.php b/src/DragonKnight/Router.php new file mode 100644 index 0000000..6869fc5 --- /dev/null +++ b/src/DragonKnight/Router.php @@ -0,0 +1,212 @@ +add($routes, 'GET', '/posts/:id', function($id) { echo "Viewing post $id"; });` + */ + public function add(string $method, string $route, callable $handler): Router + { + $this->validateMethod($method); + $this->validateRoute($route); + + $segments = $route === '/' ? [''] : explode('/', trim($route, '/')); + + $node = &$this->routes; + foreach ($segments as $segment) { + $segment = str_starts_with($segment, ':') ? ':x' : $segment; + if ($segment === '') { + continue; + } + $node = &$node[$segment]; + } + + $node[$method] = [ + 'handler' => $handler, + 'middleware' => [], + ]; + + $this->last_inserted_node = &$node[$method]; + + return $this; + } + + /** + * Perform a lookup in the route tree for a given method and URI. Returns an array with a result code, + * a handler if found, and any dynamic parameters. Codes are 200 for success, 404 for not found, and + * 405 for method not allowed. + * + * @return array ['code', 'handler', 'params'] + */ + public function lookup(string $method, string $uri): array|int + { + $node = $this->routes; + $params = []; + + if ($uri === '/') { + return $node[$method] ?? 405; + } + + foreach (explode('/', trim($uri, '/')) as $segment) { + if (isset($node[$segment])) { + $node = $node[$segment]; + continue; + } + + if (isset($node[':x'])) { + $params[] = $segment; + $node = $node[':x']; + continue; + } + + return 404; + } + + $node[$method]['params'] = $params; + + return $node[$method] ?? 405; + } + + /** + * Add a middleware function to the last inserted node's stack. + */ + public function middleware(callable $middleware): Router + { + $this->last_inserted_node['middleware'][] = $middleware; + + return $this; + } + + /** + * Shorthand to register a GET route. + */ + public function get(string $route, callable $handler): Router + { + return $this->add('GET', $route, $handler); + } + + /** + * Shorthand to register a POST route. + */ + public function post(string $route, callable $handler): Router + { + return $this->add('POST', $route, $handler); + } + + /** + * Shorthand to register a PUT route. + */ + public function put(string $route, callable $handler): Router + { + return $this->add('PUT', $route, $handler); + } + + /** + * Shorthand to register a DELETE route. + */ + public function delete(string $route, callable $handler): Router + { + return $this->add('DELETE', $route, $handler); + } + + /** + * Shorthand to register a PATCH route. + */ + public function patch(string $route, callable $handler): Router + { + return $this->add('PATCH', $route, $handler); + } + + /** + * Register multiple verbs to the same route. + */ + public function many(array $methods, string $route, callable $handler): Router + { + foreach ($methods as $method) { + $this->add($method, $route, $handler); + } + + return $this; + } + + /** + * Register all verbs to the same route. + */ + public function any(string $route, callable $handler): Router + { + foreach (self::VALID_METHODS as $method) { + $this->add($method, $route, $handler); + } + + return $this; + } + + /** + * Some pages function entirely as forms; thus we can shorthand a GET/POST route. + */ + public function form(string $route, callable $handler): Router + { + return $this->many(['GET', 'POST'], $route, $handler); + } + + /** + * Validate the given method against valid HTTP verbs. + */ + private function validateMethod(string $method): void + { + if (! in_array($method, self::VALID_METHODS)) { + throw new \InvalidArgumentException("Invalid HTTP method: $method"); + } + } + + /** + * Validate that a new route follows expected formatting. + */ + private function validateRoute(string $route): void + { + if ($route === '') { + throw new \InvalidArgumentException('Route cannot be empty'); + } + + // Ensure route starts with a slash + if (! str_starts_with($route, '/')) { + throw new \InvalidArgumentException("Route must start with a '/'"); + } + } +} diff --git a/src/actions/admin.php b/src/actions/admin.php deleted file mode 100644 index 7f846ec..0000000 --- a/src/actions/admin.php +++ /dev/null @@ -1,566 +0,0 @@ -authlevel === 1) { - $r->get('/admin', 'Admin\donothing'); - - $r->form('/admin/main', 'Admin\primary'); - - $r->get('/admin/items', 'Admin\items'); - $r->form('/admin/items/:id', 'Admin\edit_item'); - - $r->get('/admin/drops', 'Admin\drops'); - $r->form('/admin/drops/:id', 'Admin\edit_drop'); - - $r->get('/admin/towns', 'Admin\towns'); - $r->form('/admin/towns/:id', 'Admin\edit_town'); - - $r->get('/admin/monsters', 'Admin\monsters'); - $r->form('/admin/monsters/:id', 'Admin\edit_monster'); - - $r->get('/admin/levels', 'Admin\levels'); - $r->post('/admin/levels', 'Admin\edit_level'); - - $r->get('/admin/spells', 'Admin\spells'); - $r->form('/admin/spells/:id', 'Admin\edit_spell'); - - $r->get('/admin/users', 'Admin\users'); - $r->form('/admin/users/:id', 'Admin\edit_user'); - - $r->form('/admin/news', 'Admin\add_news'); - } - return $r; -} - -/** - * Home page for the admin panel. - */ -function donothing(): string -{ - page_title('Admin'); - - return <<
- Please note that the control panel has been created mostly as a shortcut for certain individual settings. It is - meant for use primarily with editing one thing at a time. If you need to completely replace an entire table - (say, to replace all stock monsters with your own new ones), it is suggested that you use a more in-depth - database tool such as SQLite Browser. -

- Also, you should be aware that certain portions of the DK code are dependent on the formatting of certain - database results (for example, the special attributes on item drops). While I have attempted to point these out - throughout the admin script, you should definitely pay attention and be careful when editing some fields, - because mistakes in the database content may result in script errors or your game breaking completely. - HTML; -} - -/** - * Main settings that get written to .env - */ -function primary(): string -{ - if (is_post()) { - $form = validate($_POST, [ - 'gamename' => ['alphanum-spaces'], - 'gamesize' => ['int', 'min:5'], - 'class1name' => ['alpha-spaces'], - 'class2name' => ['alpha-spaces'], - 'class3name' => ['alpha-spaces'], - 'gameopen' => ['bool'], - 'verifyemail' => ['bool'], - 'shownews' => ['bool'], - 'showonline' => ['bool'], - 'showbabble' => ['bool'] - ]); - - if ($form['valid']) { - $form = $form['data']; - if (($form['gamesize'] % 5) != 0) exit('Map size must be divisible by five.'); - - // @todo - // write changes to .env - - $page = 'Main settings updated.'; - } else { - $error_list = ul_from_validate_errors($form['errors']); - $page = <<Errors:
-
{$error_list}

- Please go back and try again. - HTML; - } - } else { - $page = render('admin/main_settings'); - } - - page_title('Admin: Main Settings'); - return $page; -} - -/** - * Show the full list of items that can be edited. - */ -function items(): string -{ - $items = db()->query('SELECT * FROM items ORDER BY id;'); - $page = "

Edit Items

Click an item's name or ID to edit it.

\n"; - $page .= build_bulk_table($items, 'name', '/admin/items'); - - page_title('Admin: Items'); - return $page; -} - -/** - * Shows the form for editing an item via GET, processes edits via POST - */ -function edit_item(int $id): string -{ - $item = get_item($id); - - if (is_post()) { - $page = handle_edit_form($id, 'items', validate($_POST, [ - 'name' => [], - 'type' => ['int', 'in:1,2,3'], - 'buycost' => ['int', 'min:0'], - 'attribute' => ['int', 'min:0'], - 'special' => ['default:X'] - ])); - } else { - $page = render('admin/edit_item', ['item' => $item]); - } - - page_title('Admin: Editing '.$item['name']); - return $page; -} - -/** - * Show the full list of drops that can be edited - */ -function drops() -{ - $drops = db()->query('SELECT * FROM drops ORDER BY id;'); - $page = "

Edit Drops

Click an item's name to edit it.

\n"; - $page .= build_bulk_table($drops, 'name', '/admin/drops'); - - page_title('Admin: Drops'); - return $page; -} - -/** - * Show the form to edit drops via GET, process those edits via POST - */ -function edit_drop(int $id): string -{ - $drop = get_drop($id); - - if (is_post()) { - $page = handle_edit_form($id, 'drops', validate($_POST, [ - 'name' => [], - 'mlevel' => ['int', 'min:1'], - 'attribute1' => [], - 'attribute2' => ['default:X'], - ])); - } else { - $page = render('admin/edit_drop', ['drop' => $drop]); - } - - page_title('Admin: Editing '.$drop['name']); - return $page; -} - -/** - * Generate the list of towns that can be edited. - */ -function towns(): string -{ - $towns = db()->query('SELECT * FROM towns ORDER BY id;'); - $page = "

Edit Towns

Click an town's name or ID to edit it.

\n"; - $page .= build_bulk_table($towns, 'name', '/admin/towns'); - - page_title('Admin: Towns'); - return $page; -} - -/** - * Save any changes to the town made. - */ -function edit_town(int $id): string -{ - $town = get_town_by_id($id); - - if (is_post()) { - $page = handle_edit_form($id, 'towns', validate($_POST, [ - 'name' => [], - 'latitude' => ['int', 'min:0', 'max:'.env('game_size')], - 'longitude' => ['int', 'min:0', 'max:'.env('game_size')], - 'innprice' => ['int', 'min:0'], - 'mapprice' => ['int', 'min:0'], - 'travelpoints' => ['int', 'min:0'], - 'itemslist' => ['optional'] - ])); - } else { - $page = render('admin/edit_town', ['town' => $town]); - } - - page_title('Admin: Editing '.$town['name']); - return $page; -} - -/** - * List the monsters available to edit. - */ -function monsters() -{ - $max_level = db()->query('SELECT level FROM monsters ORDER BY level DESC LIMIT 1;')->fetchArray(SQLITE3_ASSOC)['level']; - $monsters = db()->query('SELECT * FROM monsters ORDER BY id;'); - - $page = "

Edit Monsters

"; - - if ((env('game_size') / 5) !== $max_level) { - $page .= "Note: Your highest monster level does not match with your entered map size. Highest monster level should be ".(env('game_size') / 5).", yours is $max_level. Please fix this before opening the game to the public.
"; - } else { - $page .= "Monster level and map size match. No further actions are required for map compatibility.
"; - } - - $page .= "Click an monster's name or ID to edit it.

\n"; - $page .= build_bulk_table($monsters, 'name', '/admin/monsters'); - - page_title('Admin: Monsters'); - return $page; -} - -/** - * Handle the actual editing of the monster. - */ -function edit_monster(int $id): string -{ - $monster = get_monster($id); - - if (is_post()) { - $page = handle_edit_form($id, 'monsters', validate($_POST, [ - 'name' => [], - 'maxhp' => ['int', 'min:1'], - 'maxdam' => ['int', 'min:0'], - 'armor' => ['int', 'min:0'], - 'level' => ['int', 'min:1'], - 'maxexp' => ['int', 'min:0'], - 'maxgold' => ['int', 'min:0'], - 'immune' => ['in:0,1,2'] - ])); - } else { - $page = render('admin/edit_monster', ['monster' => $monster]); - } - - page_title('Admin: Editing '.$monster['name']); - return $page; -} - -/** - * List all spells available to edit. - */ -function spells(): string -{ - $page = "

Edit Spells

Click an spell's name to edit it.

\n"; - - $spells = db()->query('SELECT * FROM spells ORDER BY id;'); - $page .= build_bulk_table($spells, 'name', '/admin/spells'); - - page_title('Admin: Spells'); - return $page; -} - -/** - * Handle the editing of an individual spell. - */ -function edit_spell(int $id): string -{ - $spell = get_spell($id); - - if (is_post()) { - $page = handle_edit_form($id, 'spells', validate($_POST, [ - 'name' => [], - 'mp' => ['int', 'min:0'], - 'attribute' => ['int', 'min:0'], - 'type' => ['in:1,2,3,4,5'] - ])); - } else { - $page = render('admin/edit_spell', ['spell' => $spell]); - } - - page_title('Admin: Editing '.$spell['name']); - return $page; -} - -/** - * List all editable levels. - */ -function levels(): string -{ - $max_level = db()->query('SELECT id FROM levels ORDER BY id DESC LIMIT 1;')->fetchArray(SQLITE3_ASSOC)['id']; - - $page = <<Edit Levels - Select a level number from the dropdown box to edit it.

- -
- - - - HTML; - } - - page_title('Admin: Add News'); - return $page; -} - -/** - * Build an HTML table containing all columns and rows of a given data structure. Takes a SQLiteResult3 of a SELECT - * query. - */ -function build_bulk_table(SQLite3Result $query_data, string $edit_column, string $edit_link): string -{ - $data = []; - $data_count = 0; - while ($row = $query_data->fetchArray(SQLITE3_ASSOC)) $data[$data_count++] = $row; - if ($data_count === 0) return 'No data.'; - - $columns = array_diff(array_keys($data[0]), ['password']); - - $html_parts = [ - '
{$town['name']}Already mapped.Location: $latitude $longitudeTP: {$town['travelpoints']}
{$town['name']}Price: {$town['mapprice']} goldBuy map to reveal details.
', - str_repeat('', count($columns)), - '' - ]; - - foreach ($columns as $column) { - $html_parts[] = ''; - } - $html_parts[] = ''; - - $is_edit_column = array_flip(['id', $edit_column]); - - foreach ($data as $row) { - $html_parts[] = ''; - foreach ($columns as $column) { - $name = make_safe($row[$column]); - $html_parts[] = isset($is_edit_column[$column]) - ? "" - : ""; - } - $html_parts[] = ''; - } - - $html_parts[] = '
' . - make_safe($column === 'id' ? 'ID' : ucfirst($column)) . - '
{$name}{$name}
'; - return implode('', $html_parts); -} - -/** - * Save a row of data to it's table from the data supplied. - */ -function save_data_row(string $table, array $data, int $id): SQLite3Result|false -{ - $data = array_filter($data, fn($value) => $value !== null && $value !== ''); - if (empty($data)) return false; - - $fields = implode(',', array_map(fn($key) => "`$key`=?", array_keys($data))); - $values = array_values($data); - $values[] = $id; - - return db()->query("UPDATE $table SET $fields WHERE id=?", $values); -} - -/** - * Handle the result of a generic edit form. - */ -function handle_edit_form(int $id, string $table, array $form, string $updated_message = ''): string -{ - if ($form['valid']) { - save_data_row($table, $form['data'], $id); - $page = $updated_message ?: ''.$form['data']['name'].' updated.'; - } else { - $error_list = ul_from_validate_errors($form['errors']); - $page = <<Errors:
-
{$error_list}

- Please go back and try again. - HTML; - } - - return $page; -} diff --git a/src/actions/explore.php b/src/actions/explore.php deleted file mode 100644 index 470a6fc..0000000 --- a/src/actions/explore.php +++ /dev/null @@ -1,69 +0,0 @@ -Exploring - You are exploring the map, and nothing has happened. Continue exploring using the direction buttons or the Travel To menus. - HTML; -} - -function move() { - // Early exit if fighting - if (user()->currentaction == 'Fighting') redirect('/fight'); - - // Validate direction - $form = validate($_POST, ['direction' => ['in:north,west,east,south']]); - if (!$form['valid']) return ul_from_validate_errors($form['errors']); - - // Current game state - $game_size = env('game_size'); - $latitude = user()->latitude; - $longitude = user()->longitude; - $direction = $form['data']['direction']; - - // Calculate new coordinates with boundary checks - switch ($direction) { - case 'north': - $latitude = min($latitude + 1, $game_size); - break; - case 'south': - $latitude = max($latitude - 1, -$game_size); - break; - case 'east': - $longitude = min($longitude + 1, $game_size); - break; - case 'west': - $longitude = max($longitude - 1, -$game_size); - break; - } - - // Check for town - $town = get_town_by_xy($longitude, $latitude); - if ($town !== false) { - return \Towns\travelto($town['id'], false); - } - - // Determine action (1 in 5 chance of fighting) - if (rand(1, 5) === 1) { - user()->currentaction = 'Fighting'; - user()->currentfight = 1; - } else { - user()->currentaction = 'Exploring'; - } - - user()->latitude = $latitude; - user()->longitude = $longitude; - user()->save(); - - return index(); -} diff --git a/src/actions/fight.php b/src/actions/fight.php deleted file mode 100644 index 0d8f494..0000000 --- a/src/actions/fight.php +++ /dev/null @@ -1,451 +0,0 @@ -form('/fight', 'Fights\fight'); - $r->get('/victory', 'Fights\victory'); - $r->form('/drop', 'Fights\drop'); - $r->get('/dead', 'Fights\dead'); - return $r; -} - -/** - * One big long function that determines the outcome of the fight. - */ -function fight() -{ - if (user()->currentaction !== 'Fighting') exit('Cheat attempt detected.

Get a life, loser.'); - - $page = ["magiclist" => "", "yourturn" => "", "monsterturn" => "", "monsterhp" => "", "command" => ""]; - $playerisdead = 0; - - // Generate spell list - $user_spells = user()->spells(); - if (!empty($user_spells)) { - $page['magiclist'] = '

'; - } - - // Determine initial combat parameters - $chancetoswingfirst = rand(1, 10) + (int)ceil(sqrt(user()->dexterity)); - if (user()->currentfight === 1) { - $maxlevel = (int)floor(max(abs(user()->latitude) + 5, abs(user()->longitude) + 5) / 5); - $minlevel = max(1, $maxlevel - 2); - - $monster = db()->query('SELECT * FROM monsters WHERE level >= ? AND level <= ? ORDER BY RANDOM() LIMIT 1;', [ - $minlevel, $maxlevel - ])->fetchArray(SQLITE3_ASSOC); - - user()->currentmonster = $monster["id"]; - user()->currentmonsterhp = rand((int)(($monster["maxhp"]/5)*4), $monster["maxhp"]); - user()->currentmonstersleep = 0; - user()->currentmonsterimmune = $monster["immune"]; - - $chancetoswingfirst = ($chancetoswingfirst > (rand(1,7) + (int)ceil(sqrt($monster["maxdam"])))) ? 1 : 0; - } - - // Get monster statistics - $monster = get_monster(user()->currentmonster); - $page['monstername'] = $monster['name']; - - // Run action - if (isset($_POST["run"])) { - $chancetorun = rand(4,10) + (int)ceil(sqrt(user()->dexterity)); - if ($chancetorun <= (rand(1,5) + (int)ceil(sqrt($monster["maxdam"])))) { - $page["yourturn"] = "You tried to run away, but were blocked in front!

"; - $page["monsterhp"] = "Monster's HP: " . user()->currentmonsterhp . "

"; - - // Monster turn logic (similar to original function) - $page["monsterturn"] = handleMonsterTurn($userrow, $monster); - - user()->currentaction = 'Exploring'; - user()->save(); - redirect('/'); - } - } - - // Fight action - if (isset($_POST["fight"])) { - // Player's attack - $min = (int)(user()->attackpower * 0.75); - $max = (int)(user()->attackpower / 3); - $tohit = (int)ceil(mt_rand(min($min, $max), max($min, $max))); - - $toexcellent = rand(1,150); - if ($toexcellent <= sqrt(user()->strength)) { - $tohit *= 2; - $page["yourturn"] .= "Excellent hit!
"; - } - - $min = (int)($monster["armor"] * 0.75); - $max = (int)$monster["armor"]; - $toblock = (int)ceil(rand(min($min, $max), max($min, $max)) / 3); - - $tododge = rand(1, 100); - - $monsterdamage = max(1, $tohit - $toblock); - if ($tododge <= sqrt($monster["armor"])) { - $monsterdamage = 0; - $page["yourturn"] .= "The monster is dodging. No damage has been scored.
"; - } - - if (user()->currentuberdamage != 0) { - $monsterdamage += (int)ceil($monsterdamage * (user()->currentuberdamage / 100)); - } - - user()->currentmonsterhp -= $monsterdamage; - $page["yourturn"] .= "You attack the monster for $monsterdamage damage.

"; - $page["monsterhp"] = "Monster's HP: " . user()->currentmonsterhp . "

"; - - // Check for monster defeat - if (user()->currentmonsterhp <= 0) { - user()->currentmonsterhp = 0; - user()->save(); - redirect('/victory'); - } - - // Monster's turn - $page["monsterturn"] = handleMonsterTurn($userrow, $monster); - } - - // Spell action - if (isset($_POST["spell"])) { - $pickedspell = $_POST["userspell"]; - if ($pickedspell == 0) return 'You must select a spell first. Please go back and try again.'; - - $newspellrow = get_spell($pickedspell); - $spell = in_array($pickedspell, explode(',', user()->spells)); - - if (!$spell) return 'You have not yet learned this spell. Please go back and try again.'; - - if (user()->currentmp < $newspellrow["mp"]) { - return 'You do not have enough Magic Points to cast this spell. Please go back and try again.'; - } - - // Spell type handling (similar to original function) - $page["yourturn"] = handleSpellCast($userrow, $newspellrow); - $page["monsterhp"] = "Monster's HP: " . user()->currentmonsterhp . "

"; - - // Check for monster defeat - if (user()->currentmonsterhp <= 0) { - user()->currentmonsterhp = 0; - user()->save(); - redirect('/victory'); - } - - // Monster's turn - $page["monsterturn"] = handleMonsterTurn($userrow, $monster); - } - - // Monster's turn if player lost first swing - if (!isset($_POST["run"]) && !isset($_POST["fight"]) && !isset($_POST["spell"]) && $chancetoswingfirst == 0) { - $page["yourturn"] = "The monster attacks before you are ready!

"; - $page["monsterhp"] = "Monster's HP: " . user()->currentmonsterhp . "

"; - $page["monsterturn"] = handleMonsterTurn($userrow, $monster); - } - - // Prepare command or death message - if ($playerisdead != 1) { - $page["command"] = <<
-
-

- {$page['magiclist']} -

-
- HTML; - - user()->currentfight += 1; - } else { - $page["command"] = <<You have died.

- As a consequence, you've lost half of your gold. However, you have been given back a portion of your hit points to continue your journey.

- You may now continue back to town, and we hope you fair better next time. - HTML; - } - - user()->save(); - - // Finalize page and display it - $page = render('fight', ['page' => $page]); - return $page; -} - -function victory() -{ - if (user()->currentmonsterhp != 0) redirect('/fight'); - if (user()->currentfight == 0) redirect('/'); - - $monsterrow = get_monster(user()->currentmonster); - - $min = (int)(($monsterrow["maxexp"] / 6) * 5); - $max = (int)$monsterrow["maxexp"]; - $exp = mt_rand(min($min, $max), max($min, $max)); - if ($exp < 1) { $exp = 1; } - - if (user()->expbonus != 0) { $exp += ceil((user()->expbonus/100)*$exp); } - - $min = (int)(($monsterrow["maxgold"] / 6) * 5); - $max = (int)$monsterrow["maxgold"]; - - $gold = mt_rand(min($min, $max), max($min, $max)); - if ($gold < 1) { $gold = 1; } - - if (user()->goldbonus != 0) { $gold += ceil((user()->goldbonus/100)*$exp); } - if (user()->experience + $exp < 16777215) { $newexp = user()->experience += $exp; $warnexp = ""; } else { $newexp = user()->experience; $exp = 0; $warnexp = "You have maxed out your experience points."; } - if (user()->gold + $gold < 16777215) { $newgold = user()->gold += $gold; $warngold = ""; } else { $newgold = user()->gold; $gold = 0; $warngold = "You have maxed out your gold."; } - - $levelrow = db()->query('SELECT * FROM levels WHERE id=? LIMIT 1;', [user()->level + 1])->fetchArray(SQLITE3_ASSOC); - - if (user()->level < 100) { - if ($newexp >= $levelrow[user()->charclass."_exp"]) { - user()->maxhp += $levelrow[user()->charclass."_hp"]; - user()->maxmp += $levelrow[user()->charclass."_mp"]; - user()->maxtp += $levelrow[user()->charclass."_tp"]; - user()->strength += $levelrow[user()->charclass."_strength"]; - user()->dexterity += $levelrow[user()->charclass."_dexterity"]; - user()->attackpower += $levelrow[user()->charclass."_strength"]; - user()->defensepower += $levelrow[user()->charclass."_dexterity"]; - user()->level += 1; - $newlevel = $levelrow["id"]; - - if ($levelrow[user()->charclass."_spells"] != 0) { - user()->spells .= ",".$levelrow[user()->charclass."_spells"]; - $spelltext = "You have learned a new spell.
"; - } else { $spelltext = ""; $newspell=""; } - - $page = "Congratulations. You have defeated the ".$monsterrow["name"].".
You gain $exp experience. $warnexp
You gain $gold gold. $warngold

You have gained a level!

You gain ".$levelrow[user()->charclass."_hp"]." hit points.
You gain ".$levelrow[user()->charclass."_mp"]." magic points.
You gain ".$levelrow[user()->charclass."_tp"]." travel points.
You gain ".$levelrow[user()->charclass."_strength"]." strength.
You gain ".$levelrow[user()->charclass."_dexterity"]." dexterity.
$spelltext
You can now continue exploring."; - $title = "Courage and Wit have served thee well!"; - $dropcode = ""; - } else { - $page = "Congratulations. You have defeated the ".$monsterrow["name"].".
You gain $exp experience. $warnexp
You gain $gold gold. $warngold

"; - - if (rand(1, 30) === 1) { - $droprow = db()->query('SELECT * FROM drops WHERE mlevel <= ? ORDER BY RANDOM() LIMIT 1;', [$monsterrow['level']])->fetchArray(SQLITE3_ASSOC); - $dropcode = "dropcode='".$droprow["id"]."',"; - $page .= "This monster has dropped an item. Click here to reveal and equip the item, or you may also move on and continue exploring."; - } else { - $dropcode = ""; - $page .= 'You can now continue exploring.'; - } - - $title = "Victory!"; - } - } - - user()->currentaction = 'Exploring'; - user()->currentfight = 0; - user()->currentuberdamage = 0; - user()->currentuberdefense = 0; - user()->currentmonstersleep = 0; - user()->currentmonsterimmune = 0; - user()->save(); - - page_title($title); - return $page; -} - -function drop() -{ - if (user()->dropcode == 0) redirect('/'); - - $droprow = get_drop(user()->dropcode); - - if (isset($_POST["submit"])) { - $slot = $_POST["slot"]; - - if ($slot === 0) return 'Please go back and select an inventory slot to continue.'; - - $slotstr = 'slot'.$slot.'id'; - if (user()->$slotstr != 0) { - $slotrow = get_drop(user()->$slotstr); - - $old1 = explode(",",$slotrow["attribute1"]); - if ($slotrow["attribute2"] != "X") { $old2 = explode(",",$slotrow["attribute2"]); } else { $old2 = array(0=>"maxhp",1=>0); } - $new1 = explode(",",$droprow["attribute1"]); - if ($droprow["attribute2"] != "X") { $new2 = explode(",",$droprow["attribute2"]); } else { $new2 = array(0=>"maxhp",1=>0); } - - user()->$old1[0] -= $old1[1]; - user()->$old2[0] -= $old2[1]; - if ($old1[0] == "strength") { user()->attackpower -= $old1[1]; } - if ($old1[0] == "dexterity") { user()->defensepower -= $old1[1]; } - if ($old2[0] == "strength") { user()->attackpower -= $old2[1]; } - if ($old2[0] == "dexterity") { user()->defensepower -= $old2[1]; } - - user()->$new1[0] += $new1[1]; - user()->$new2[0] += $new2[1]; - if ($new1[0] == "strength") { user()->attackpower += $new1[1]; } - if ($new1[0] == "dexterity") { user()->defensepower += $new1[1]; } - if ($new2[0] == "strength") { user()->attackpower += $new2[1]; } - if ($new2[0] == "dexterity") { user()->defensepower += $new2[1]; } - - if (user()->currenthp > user()->maxhp) { user()->currenthp = user()->maxhp; } - if (user()->currentmp > user()->maxmp) { user()->currentmp = user()->maxmp; } - if (user()->currenttp > user()->maxtp) { user()->currenttp = user()->maxtp; } - - $slot_s = 'slot'.$_POST["slot"]; - $slot_name = "{$slot_s}name"; - $slot_id = "{$slot_s}id"; - - user()->$slot_name = $droprow['name']; - user()->$slot_id = $droprow['id']; - } else { - $new1 = explode(",",$droprow["attribute1"]); - if ($droprow["attribute2"] != "X") { $new2 = explode(",",$droprow["attribute2"]); } else { $new2 = array(0=>"maxhp",1=>0); } - - user()->$new1[0] += $new1[1]; - user()->$new2[0] += $new2[1]; - if ($new1[0] == "strength") { user()->attackpower += $new1[1]; } - if ($new1[0] == "dexterity") { user()->defensepower += $new1[1]; } - if ($new2[0] == "strength") { user()->attackpower += $new2[1]; } - if ($new2[0] == "dexterity") { user()->defensepower += $new2[1]; } - - $slot_s = 'slot'.$_POST["slot"]; - $slot_name = "{$slot_s}name"; - $slot_id = "{$slot_s}id"; - - user()->$slot_name = $droprow['name']; - user()->$slot_id = $droprow['id']; - } - - user()->save(); - return 'The item has been equipped. You can now continue exploring.'; - } - - $attributearray = array("maxhp"=>"Max HP", - "maxmp"=>"Max MP", - "maxtp"=>"Max TP", - "defensepower"=>"Defense Power", - "attackpower"=>"Attack Power", - "strength"=>"Strength", - "dexterity"=>"Dexterity", - "expbonus"=>"Experience Bonus", - "goldbonus"=>"Gold Bonus"); - - $page = "The monster dropped the following item: ".$droprow["name"]."

"; - $page .= "This item has the following attribute(s):
"; - - $attribute1 = explode(",",$droprow["attribute1"]); - $page .= $attributearray[$attribute1[0]]; - if ($attribute1[1] > 0) { $page .= " +" . $attribute1[1] . "
"; } else { $page .= $attribute1[1] . "
"; } - - if ($droprow["attribute2"] != "X") { - $attribute2 = explode(",",$droprow["attribute2"]); - $page .= $attributearray[$attribute2[0]]; - if ($attribute2[1] > 0) { $page .= " +" . $attribute2[1] . "
"; } else { $page .= $attribute2[1] . "
"; } - } - - $page .= "
Select an inventory slot from the list below to equip this item. If the inventory slot is already full, the old item will be discarded."; - $page .= "
"; - $page .= "You may also choose to just continue exploring and give up this item."; - - return $page; -} - - -function dead() -{ - return <<You have died.

- As a consequence, you've lost half of your gold. However, you have been given back a portion of your hit points - to continue your journey.

- You may now continue back to town, and we hope you fair better next time. - HTML; -} - -function handleMonsterTurn(&$userrow, $monsterrow) -{ - $pagearray = ""; - if (user()->currentmonstersleep != 0) { - $chancetowake = rand(1,15); - if ($chancetowake > user()->currentmonstersleep) { - user()->currentmonstersleep = 0; - $pagearray .= "The monster has woken up.
"; - } else { - $pagearray .= "The monster is still asleep.
"; - } - } - - if (user()->currentmonstersleep == 0) { - $tohit = (int)ceil(mt_rand((int)($monsterrow["maxdam"] * 0.5), (int)$monsterrow["maxdam"])); - $toblock = (int)ceil(mt_rand((int)(user()->defensepower * 0.75), (int)user()->defensepower) / 4); - $tododge = rand(1, 150); - - if ($tododge <= sqrt(user()->dexterity)) { - $tohit = 0; - $pagearray .= "You dodge the monster's attack. No damage has been scored.
"; - $persondamage = 0; - } else { - $persondamage = max(1, $tohit - $toblock); - if (user()->currentuberdefense != 0) { - $persondamage -= (int)ceil($persondamage * (user()->currentuberdefense/100)); - } - $persondamage = max(1, $persondamage); - } - - $pagearray .= "The monster attacks you for $persondamage damage.

"; - user()->currenthp -= $persondamage; - - if (user()->currenthp <= 0) { - $newgold = (int)ceil(user()->gold/2); - $newhp = (int)ceil(user()->maxhp/4); - db()->query("UPDATE users SET currenthp=?, currentaction='In Town', currentmonster=0, currentmonsterhp=0, currentmonstersleep=0, currentmonsterimmune=0, currentfight=0, latitude=0, longitude=0, gold=? WHERE id=?;", [ - $newhp, $newgold, $userrow['id'] - ]); - dead(); - } - } - return $pagearray; -} - -function handleSpellCast(&$userrow, $newspellrow) -{ - $pagearray = ""; - switch ($newspellrow["type"]) { - case 1: // Heal spell - $newhp = min(user()->currenthp + $newspellrow["attribute"], user()->maxhp); - user()->currenthp = $newhp; - user()->currentmp -= $newspellrow["mp"]; - $pagearray = "You have cast the {$newspellrow["name"]} spell, and gained {$newspellrow["attribute"]} Hit Points.

"; - break; - case 2: // Hurt spell - if (user()->currentmonsterimmune == 0) { - $monsterdamage = mt_rand((int)(($newspellrow["attribute"]/6)*5), $newspellrow["attribute"]); - user()->currentmonsterhp -= $monsterdamage; - $pagearray = "You have cast the {$newspellrow["name"]} spell for $monsterdamage damage.

"; - } else { - $pagearray = "You have cast the {$newspellrow["name"]} spell, but the monster is immune to it.

"; - } - user()->currentmp -= $newspellrow["mp"]; - break; - case 3: // Sleep spell - if (user()->currentmonsterimmune != 2) { - user()->currentmonstersleep = $newspellrow["attribute"]; - $pagearray = "You have cast the {$newspellrow["name"]} spell. The monster is asleep.

"; - } else { - $pagearray = "You have cast the {$newspellrow["name"]} spell, but the monster is immune to it.

"; - } - user()->currentmp -= $newspellrow["mp"]; - break; - case 4: // +Damage spell - user()->currentuberdamage = $newspellrow["attribute"]; - user()->currentmp -= $newspellrow["mp"]; - $pagearray = "You have cast the {$newspellrow["name"]} spell, and will gain {$newspellrow["attribute"]}% damage until the end of this fight.

"; - break; - case 5: // +Defense spell - user()->currentuberdefense = $newspellrow["attribute"]; - user()->currentmp -= $newspellrow["mp"]; - $pagearray = "You have cast the {$newspellrow["name"]} spell, and will gain {$newspellrow["attribute"]}% defense until the end of this fight.

"; - break; - } - return $pagearray; -} diff --git a/src/actions/forum.php b/src/actions/forum.php deleted file mode 100644 index aa50a1f..0000000 --- a/src/actions/forum.php +++ /dev/null @@ -1,115 +0,0 @@ -get('/forum/thread/:x/:x', 'Forum\showthread'); - $r->form('/forum/new', 'Forum\newthread'); - $r->post('/forum/reply', 'Forum\reply'); - $r->get('/forum/list/:x', 'Forum\donothing'); - $r->get('/forum', 'Forum\donothing'); - return $r; -} - -function donothing($start = 0) -{ - $query = db()->query('SELECT * FROM forum WHERE parent=0 ORDER BY newpostdate DESC LIMIT 20 OFFSET ?;', [20 * $start]); - $page = << - - - - - - - - - - - - HTML; - - $hasRows = false; - - while ($row = $query->fetchArray(SQLITE3_ASSOC)) { - $hasRows = true; - $page .= << - - - - - HTML; - } - - if (!$hasRows) { - $page .= ''; - } - - $page .= '
-
New Thread
-
ThreadRepliesLast Post
{$row['title']}{$row['replies']}{$row['newpostdate']}
No threads in forum.
'; - - page_title('Forum'); - return $page; -} - -function showthread($id, $start) -{ - $posts = db()->query('SELECT * FROM forum WHERE id=? OR parent=? ORDER BY id LIMIT 15 OFFSET ?;', [$id, $id, $start * 15]); - $title = db()->query('SELECT title FROM forum WHERE id=? LIMIT 1;', [$id])->fetchArray(SQLITE3_ASSOC); - - $page = "
\n"; - while ($row = $posts->fetchArray(SQLITE3_ASSOC)) { - $page .= "\n"; - } - $page .= "
Forum :: ".$title['title']."
".$row["author"]."

".pretty_date($row["postdate"])."
".nl2br($row["content"])."

"; - $page .= "
Reply To This Thread:

"; - - page_title('Forum: '.$title['title']); - return $page; -} - -function reply() -{ - $form = validate($_POST, [ - 'title' => [], - 'content' => [] - ]); - - if (!$form['valid']) exit(ul_from_validate_errors($form['errors'])); - - $form = $form['data']; - - db()->query('INSERT INTO forum (author, title, content, parent) VALUES (?, ?, ?, ?);', [ - user()->username, $form['title'], $form['content'], $form['parent'] - ]); - db()->query('UPDATE forum SET newpostdate=CURRENT_TIMESTAMP, replies=replies + 1 WHERE id=?;', [$form['parent']]); - return showthread($form['parent'], 0); -} - -function newthread() -{ - if (isset($_POST["submit"])) { - $form = validate($_POST, [ - 'title' => ['length:2-30'], - 'content' => [] - ]); - - if (!$form['valid']) exit(ul_from_validate_errors($form['errors'])); - - $form = $form['data']; - db()->query('INSERT INTO forum (author, title, content) VALUES (?, ?, ?);', [ - user()->username, $form['title'], $form['content'] - ]); - redirect('/forum/thread/'.db()->lastInsertRowID().'/0'); - } - - page_title('Form: New Thread'); - return "
Make A New Post:

Title:


Message:


"; -} diff --git a/src/actions/heal.php b/src/actions/heal.php deleted file mode 100644 index 371ccc5..0000000 --- a/src/actions/heal.php +++ /dev/null @@ -1,34 +0,0 @@ -spells(); - $spell = get_spell($id); - $has_spell = false; - foreach ($user_spells as $us) if ($us['id'] === $id) $has_spell = true; - - if ($has_spell !== true) { - $page = 'You have not yet learned this spell. Please go back and try again.'; - } elseif ($spell['type'] !== 1) { - $page = 'This is not a healing spell. Please go back and try again.'; - } elseif (user()->currentmp < $spell['mp']) { - $page = 'You do not have enough Magic Points to cast this spell. Please go back and try again.'; - } elseif (user()->currentaction === 'Fighting') { - $page = 'You cannot use the Quick Spells list during a fight. Please go back and select the Healing Spell you wish to use from the Spells box on the main fighting screen to continue.'; - } elseif (user()->currenthp == user()->maxhp) { - $page = 'Your HP is already full. You don\'t need to use a Healing spell now.'; - } else { - $restored = user()->restore_hp($spell['attribute']); - user()->currentmp -= $spell['mp']; - user()->save(); - - $page = <<exploring. - HTML; - } - - page_title('Casting '.$spell['name']); - return $page; -} diff --git a/src/actions/help.php b/src/actions/help.php deleted file mode 100644 index feed9bf..0000000 --- a/src/actions/help.php +++ /dev/null @@ -1,494 +0,0 @@ -get('/help', 'Help\main'); - $r->get('/help/items', 'Help\items'); - $r->get('/help/spells', 'Help\spells'); - $r->get('/help/monsters', 'Help\monsters'); - $r->get('/help/levels', 'Help\levels'); - return $r; -} - -function main() -{ - $page = <<Table of Contents - - -
- -

Introduction

- Firstly, I'd like to say thank you for playing my game. The Dragon Knight game engine is the result of several months of - planning, coding and testing. The original idea was to create a web-based tribute to the NES game, Dragon - Warrior. In its current iteration, only the underlying fighting system really resembles that game, as almost - everything else in DK has been made bigger and better. But you should still recognize bits and pieces as stemming - from Dragon Warrior and other RPGs of old.

- This is the first game I've ever written, and it has definitely been a positive experience. It got difficult at - times, admittedly, but it was still a lot of fun to write, and even more fun to play. And I hope to use this - experience so that if I ever want to create another game it will be even better than this one.

- If you are a site administrator, and would like to install a copy of DK on your own server, you may visit the - development site for Dragon Knight. This page - includes the downloadable game souce code, as well as some other resources that developers and administrators may - find valuable.

- Once again, thanks for playing!

- Jamin Seven
- Dragon Knight creator
- My Homepage
- Dragon Knight Homepage

- [ Top ] - -


- -

Character Classes

- There are three character classes in the game. The main differences between the classes are what spells you get - access to, the speed with which you level up, and the amount of HP/MP/strength/dexterity you gain per level. Below - is a basic outline of each of the character classes. For more detailed information about the characters, please - view the Levels table at the bottom of this page. Also, note that the outline below refers to the stock class setup - for the game. If your administrator has used his/her own class setup, this information may not be accurate.

- {env('class_1_name')} - - {env('class_2_name')} - - {env('class_3_name')} - - [ Top ] - -


- -

Playing The Game: In Town

- When you begin a new game, the first thing you see is the Town screen. Towns serve four primary functions: healing, buying items, - buying maps, and displaying game information.

- To heal yourself, click the "Rest at the Inn" link at the top of the town screen. Each town's Inn has a different price - some towns - are cheap, others are expensive. No matter what town you're in, the Inns always serve the same function: they restore your current - hit points, magic points, and travel points to their maximum amounts. Out in the field, you are free to use healing spells to restore - your hit points, but when you run low on magic points, the only way to restore them is at an Inn.

- Buying weapons and armor is accomplished through the appropriately-named "Buy Weapons/Armor" link. Not every item is available in - every town, so in order to get the most powerful items, you'll need to explore some of the outer towns. Once you've clicked the link, - you are presented with a list of items available in this town's store. To the left of each item is an icon that represents its type: - weapon, armor or shield. The amount of attack/defense power, as well as the item's price, are displayed to the right of the item name. - You'll notice that some items have a red asterisk (*) next to their names. These are items that come - with special attributes that modify other parts of your character profile. See the Items & Drops table at the bottom of this page for - more information about special items.

- Maps are the third function in towns. Buying a map to a town places the town in your Travel To box in the left status panel. Once - you've purchased a town's map, you can click its name from your Travel To box and you will jump to that town. Travelling this way - costs travel points, though, and you'll only be able to visit towns if you have enough travel points.

- The final function in towns is displaying game information and statistics. This includes the latest news post made by the game - administrator, a list of players who have been online recently, and the Babble Box.

- [ Top ] - -


- -

Playing The Game: Exploring & Fighting

- Once you're done in town, you are free to start exploring the world. Use the compass buttons on the left status panel to move around. - The game world is basically a big square, divided into four quadrants. Each quadrant is {{gamesize}} spaces - square. The first town is usually located at (0N,0E). Click the North button from the first town, and now you'll be at (1N,0E). - Likewise, if you now click the West button, you'll be at (1N,1W). Monster levels increase with every 5 spaces you move outward - from (0N,0E).

- While you're exploring, you will occasionally run into monsters. As in pretty much any other RPG game, you and the monster take turns - hitting each other in an attempt to reduce each other's hit points to zero. Once you run into a monster, the Exploring screen changes - to the Fighting screen.

- When a fight begins, you'll see the monster's name and hit points, and the game will ask you for your first command. You then get to - pick whether you want to fight, use a spell, or run away. Note, though, that sometimes the monster has the chance to hit you - first.

- The Fight button is pretty straightforward: you attack the monster, and the amount of damage dealt is based on your attack power and - the monster's armor. On top of that, there are two other things that can happen: an Excellent Hit, which doubles your total attack - damage; and a monster dodge, which results in you doing no damage to the monster.

- The Spell button allows you to pick an available spell and cast it. See the Spells list at the bottom of this page for more information - about spells.

- Finally, there is the Run button, which lets you run away from a fight if the monster is too powerful. Be warned, though: it is - possible for the monster to block you from running and attack you. So if your hit points are low, you may fare better by staying - around monsters that you know can't do much damage to you.

- Once you've had your turn, the monster also gets his turn. It is also possible for you to dodge the monster's attack and take no - damage.

- The end result of a fight is either you or the monster being knocked down to zero hit points. If you win, the monster dies and will - give you a certain amount of experience and gold. There is also a chance that the monster will drop an item, which you can put into - one of the three inventory slots to give you extra points in your character profile. If you lose and die, half of your gold is taken - away - however, you are given back a few hit points to help you make it back to town (for example, if you don't have enough gold to - pay for an Inn, and need to kill a couple low-level monsters to get the money).

- When the fight is over, you can continue exploring until you find another monster to beat into submission.

- [ Top ] - -


- -

Playing The Game: Status Panels

- There are two status panels on the game screen: left and right.

- The left panel inclues your current location and play status (In Town, Exploring, Fighting), compass buttons for movement, and the - Travel To list for jumping between towns. At the bottom of the left panel is also a list of game functions.

- The right panel displays some character statistics, your inventory, and quick spells.

- The Character section shows the most important character statistics. It also displays the status bars for your current hit points, - magic points and travel points. These status bars are colored either green, yellow or red depending on your current amount of each - stat. There is also a link to pop up your list of extended statistics, which shows more detailed character information.

- The Fast Spells section lists any Heal spells you've learned. You may use these links any time you are in town or exploring to cast - the heal spell. These may not be used during fights, however - you have to use the Spells box on the fight screen for that. - [ Top ] - -


- -

Spoilers: Items & Drops

- Click here for the Items & Drops spoiler page.

- [ Top ] - -


- -

Spoilers: Monsters

- Click here for the Monsters spoiler page.

- [ Top ] - -


- -

Spoilers: Spells

- Click here for the Spells spoiler page.

- [ Top ] - -


- -

Spoilers: Levels

- Click here for the Levels spoiler page.

- [ Top ] - -


- -

Credits

- - Apologies and lots of happy naked love to anyone I forgot.

- And of course, thanks to you for playing my game!

- NINJA!

- [ Top ] - -



- - Please visit the following sites for more information:
- Se7enet (Jamin's homepage)
- Dragon Knight (official DK homepage)
- Forums (official DK forums)

- All original coding and graphics for the Dragon Knight game engine are © 2003-2005 by Jamin Seven.

- [ Top ] - HTML; - - return display_help($page); -} - -function items() -{ - $page = << -
Items
- TypeNameCostAttributeSpecial - HTML; - - $items = db()->query('SELECT * FROM items ORDER BY id;'); - $item_types = [1 => ['weapon', 'Attack'], 2 => ['armor', 'Defense'], 3 => ['shield', 'Defense']]; - - while ($item = $items->fetchArray(SQLITE3_ASSOC)) { - $image = $item_types[$item['type']][0]; - $power = $item_types[$item['type']][1]; - - if ($item['special'] !== 'X') { - $special = explode(',', $item['special']); - $attr = special_to_string($special[0]); - $stat = (($special[1] > 0) ? '+' : '') . $special[1]; - $bigspecial = "$attr $stat"; - } else { - $bigspecial = 'None'; - } - - $page .= "\"$image\"".$item["name"]."".$item["buycost"]." Gold".$item["attribute"]." $power Power$bigspecial\n"; - } - - $page .= << - -

- - - - - HTML; - - $drops = db()->query('SELECT * FROM drops ORDER BY id;'); - - while ($drop = $drops->fetchArray(SQLITE3_ASSOC)) { - if ($drop["attribute1"] !== "X") { - $special = explode(',', $drop['attribute1']); - $attr = special_to_string($special[0]); - $stat = (($special[1] > 0) ? '+' : '') . $special[1]; - $bigspecial1 = "$attr $stat"; - } else { - $bigspecial1 = 'None'; - } - - if ($drop["attribute2"] !== "X") { - $special = explode(',', $drop['attribute2']); - $attr = special_to_string($special[0]); - $stat = (($special[1] > 0) ? '+' : '') . $special[1]; - $bigspecial2 = "$attr $stat"; - } else { - $bigspecial2 = 'None'; - } - - $page .= "\n"; - } - $page .= '
Drops
NameMonster LevelAttribute 1Attribute 2
".$drop["name"]."".$drop["mlevel"]."$bigspecial1$bigspecial2
'; - - return display_help($page); -} - - -function spells() -{ - $page = << -
Spells
- NameCostTypeAttribute - HTML; - - $spells = db()->query('SELECT * FROM spells ORDER BY id;'); - $spell_types = ['None', 'Heal', 'Hurt', 'Sleep', '+Damage (%)', '+Defense (%)']; - while ($spell = $spells->fetchArray(SQLITE3_ASSOC)) { - $page .= <<{$spell["name"]} - {$spell["mp"]} - {$spell_types[$spell['type']]} - {$spell["attribute"]} - HTML; - } - - $page .= << - - HTML; - - return display_help($page); -} - -function monsters() -{ - $page = << -
Monsters
- NameMax HPMax DamageArmorLevelMax Exp.Max GoldImmunity - HTML; - - $monsters = db()->query('SELECT * FROM monsters ORDER BY id;'); - $immunities = ['None', 'Hurt', 'Hurt & Sleep']; - - while ($m = $monsters->fetchArray(SQLITE3_ASSOC)) { - $immune = $immunities[$m['immune']] ?? 'Unknown'; - $page .= "".$m["name"]."".$m["maxhp"]."".$m["maxdam"]."".$m["armor"]."".$m["level"]."".$m["maxexp"]."".$m["maxgold"]."$immune\n"; - } - - return display_help($page.''); -} - -function levels() -{ - $rows = []; - - $levels = db()->query('SELECT * FROM levels ORDER BY id;'); - while ($level = $levels->fetchArray(SQLITE3_ASSOC)) { - $class_data = [1 => [], 2 => [], 3 => []]; - - foreach ($level as $column => $value) { - if ($column === 'id') continue; - - $parts = explode('_', $column); - $class_number = (int) $parts[0]; - $attribute = $parts[1]; - - if (in_array($class_number, [1, 2, 3])) { - $class_data[$class_number][$level['id']][$attribute] = $value; - } - } - - $rows[$level['id']] = $class_data; - } - - $spells = []; - $spells_query = db()->query('SELECT * FROM spells ORDER BY id;'); - while ($spell = $spells_query->fetchArray(SQLITE3_ASSOC)) { - $spells[$spell['id']] = $spell; - } - - $page = << -
{{class1name}} Levels
- LevelExp.HPMPTPStrengthDexteritySpell - HTML; - foreach ($rows as $level => $class_data) { - $level_data = $class_data[1][$level]; - - $spell_name = ''; - if (!empty($level_data['spells'])) $spell_name = $spells[$level_data['spells']]['name']; - - $page .= ''; - $page .= '' . $level . ''; - $page .= '' . ($level_data['exp'] ?? '') . ''; - $page .= '' . ($level_data['hp'] ?? '') . ''; - $page .= '' . ($level_data['mp'] ?? '') . ''; - $page .= '' . ($level_data['tp'] ?? '') . ''; - $page .= '' . ($level_data['strength'] ?? '') . ''; - $page .= '' . ($level_data['dexterity'] ?? '') . ''; - $page .= '' . (!empty($spell_name) ? $spell_name : 'None') . ''; - $page .= ''; - } - - $page .= << - -

- - - - - HTML; - - foreach ($rows as $level => $class_data) { - $level_data = $class_data[2][$level]; - - $spell_name = ''; - if (!empty($level_data['spells'])) $spell_name = $spells[$level_data['spells']]['name']; - - $page .= ''; - $page .= ''; - $page .= ''; - $page .= ''; - $page .= ''; - $page .= ''; - $page .= ''; - $page .= ''; - $page .= ''; - $page .= ''; - } - - $page .= << - -

- -
{{class2name}} Levels
LevelExp.HPMPTPStrengthDexteritySpell
' . $level . '' . ($level_data['exp'] ?? '') . '' . ($level_data['hp'] ?? '') . '' . ($level_data['mp'] ?? '') . '' . ($level_data['tp'] ?? '') . '' . ($level_data['strength'] ?? '') . '' . ($level_data['dexterity'] ?? '') . '' . (!empty($spell_name) ? $spell_name : 'None') . '
- - - HTML; - - foreach ($rows as $level => $class_data) { - $level_data = $class_data[3][$level]; - - $spell_name = ''; - if (!empty($level_data['spells'])) $spell_name = $spells[$level_data['spells']]['name']; - - $page .= ''; - $page .= ''; - $page .= ''; - $page .= ''; - $page .= ''; - $page .= ''; - $page .= ''; - $page .= ''; - $page .= ''; - $page .= ''; - } - - $page .= << - -
- - Experience points listed are total values up until that point. All other values are just the new amount that you gain for each level. - HTML; - - return display_help($page); -} - -function display_help(string $content) -{ - return render('layouts/help', [ - 'content' => $content, - 'version' => VERSION, - 'build' => BUILD - ]); -} diff --git a/src/actions/install.php b/src/actions/install.php deleted file mode 100644 index df4be8a..0000000 --- a/src/actions/install.php +++ /dev/null @@ -1,767 +0,0 @@ -get('/install', 'Install\first'); - $r->get('/install/second', 'Install\second'); - $r->get('/install/third', 'Install\third'); - $r->post('/install/fourth', 'Install\fourth'); - $r->get('/install/fifth', 'Install\fifth'); - } - return $r; -} - -/** - * First page - show warnings and gather info - */ -function first() -{ - return << - - Dragon Knight Installation - - - - Dragon Knight Installation: Page One

- Installation for Dragon Knight is a simple two-step process: set up the database tables, then create the admin user. After that, you're done.

- - - - HTML; -} - -/** - * Set up database tables. - */ -function second() -{ - if (file_exists('../database.db')) unlink('../database.db'); - - $page = "Dragon Knight InstallationDragon Knight Installation: Page Two

"; - - $query = db()->exec(<<exec(<<exec(<<exec(<<exec(<<exec(<<exec(<<exec(<<exec(<<exec(<<exec(<<exec(<<exec(<<exec("INSERT INTO news (content) VALUES ('This is the first news post. Please use the admin control panel to add another one and make this one go away.');"); - - $page .= table_status_msg($query === true, 'News', 'populate'); - - $query = db()->exec(<<exec(<<exec(<<exec(<<exec(<<Database setup complete in $time seconds.

Click here to continue with installation."; -} - -/** - * Gather user info for admin account. - */ -function third() -{ - return << - - Dragon Knight Installation - - - Dragon Knight Installation: Page Three

- Now you must create an administrator account so you can use the control panel. Fill out the form below to create your account. You will be able to customize the class names through the control panel once your admin account is created.

-
-
{{class3name}} Levels
LevelExp.HPMPTPStrengthDexteritySpell
' . $level . '' . ($level_data['exp'] ?? '') . '' . ($level_data['hp'] ?? '') . '' . ($level_data['mp'] ?? '') . '' . ($level_data['tp'] ?? '') . '' . ($level_data['strength'] ?? '') . '' . ($level_data['dexterity'] ?? '') . '' . (!empty($spell_name) ? $spell_name : 'None') . '
- - - - - - - -
Username:


Password:
Verify Password:


Email Address:
Verify Email:


Character Class:
- - - - HTML; -} - -/** - * Final page: insert new user row, congratulate the person on a job well done. - */ -function fourth() -{ - $form = validate($_POST, [ - 'username' => ['length:3-18', 'alpha-spaces'], - 'email' => ['email'], - 'confirm_email' => ['confirm'], - 'password' => ['length:6-255'], - 'confirm_password' => ['confirm'] - ]); - - if (!$form['valid']) exit(ul_from_validate_errors($form['errors'])); - - $form = $form['data']; - if (db()->query( - "INSERT INTO users (username, password, email, verify, charclass, authlevel) VALUES (?, ?, ?, 'g2g', ?, 1)", - [$form['username'], password_hash($form['password'], PASSWORD_ARGON2ID), $form['email'], $form['charclass']] - ) === false) { - exit("Failed to create user."); - } - - file_put_contents('../.installed', date('Y-m-d H:i:s')); - - return << - - Dragon Knight Installation - - - Dragon Knight Installation: Page Four

- Your admin account was created successfully. Installation is complete.

- Be sure to delete install.php from your Dragon Knight directory for security purposes.

- You are now ready to play the game. Note that you must log in through the public section before being allowed into the control panel. Once logged in, an "Admin" link will appear in the Functions box of the left sidebar panel.

- Thank you for using Dragon Knight!

-----

- Optional: Dragon Knight is a free product, and does not require registration of any sort. However, there is an - optional "call home" function in the installer, which notifies the author of your game installation. The ONLY information - transmitted with this function is the URL to your game. This is included mainly to satisfy the author's curiosity about - how many copies of the game are being installed and used. If you choose to submit your URL to the author, please - click here. - - - HTML; -} - -/** - * Call Home function. - */ -function fifth() -{ - if (mail("sky@sharkk.net", "Dragon Knight Call Home", $_SERVER["SERVER_NAME"].$_SERVER["PHP_SELF"]) !== true) { - exit('Dragon Knight was unable to send your URL. Please go back and try again, or just continue on to the game.'); - } - - return << - - Dragon Knight Installation - - - Dragon Knight Installation: Page Five

- Thank you for submitting your URL!

- You are now ready to play the game. Note that you must log in through the public section before being allowed into the control panel. Once logged in, an "Admin" link will appear in the Functions box of the left sidebar panel. - - - HTML; -} - -function table_status_msg(bool $condition, string $table_name, string $verb): string -{ - $verb = match($verb) { - 'create' => ['created', 'creating'], - 'populate' => ['populated', 'populating'] - }; - - if ($condition === false) { - return "Error {$verb[1]} $table_name table. (".db()->lastErrorMsg().")
"; - } - - return "$table_name table {$verb[0]}.
"; -} diff --git a/src/actions/towns.php b/src/actions/towns.php deleted file mode 100644 index 3506b2a..0000000 --- a/src/actions/towns.php +++ /dev/null @@ -1,401 +0,0 @@ -form('/inn', 'Towns\inn'); - $r->get('/shop', 'Towns\shop'); - $r->form('/buy/:id', 'Towns\buy'); - // $r->get('/sell', 'Towns\sell'); - $r->get('/maps', 'Towns\maps'); - $r->form('/maps/:id', 'Towns\buy_map'); - $r->get('/gotown/:id', 'Towns\travelto'); - return $r; -} - -/** - * Spit out the main town page. - */ -function town() -{ - $town = get_town_by_xy(user()->longitude, user()->latitude); - if ($town === false) exit('There is an error with your user account, or with the town data. Please try again.'); - - $page = ['news' => '', 'whos_online' => '']; - - // News box. Grab latest news entry and display it. Something a little more graceful coming soon maybe. - if (env('show_news')) { - $news = db()->query('SELECT * FROM news ORDER BY id DESC LIMIT 1;')->fetchArray(SQLITE3_ASSOC); - $news_date = pretty_date($news["postdate"]); - $news_content = nl2br($news["content"]); - $page['news'] = <<Latest News - $news_date
- $news_content - HTML; - } - - // Who's Online. Currently just members. Guests maybe later. - if (env('show_online')) { - $onlinequery = db()->query(<<= datetime('now', '-600 seconds') - ORDER BY username; - SQL); - - $online_count = 0; - $online_rows = []; - - while ($onlinerow = $onlinequery->fetchArray(SQLITE3_ASSOC)) { - $online_count++; - $online_rows[] = "".$onlinerow["username"].""; - } - - $online_rows = implode(', ', $online_rows); - $page['whos_online'] = <<Who's Online - There are $online_count user(s) online within the last 10 minutes: $online_rows - HTML; - } - - page_title($town['name']); - return render('towns', ['town' => $town, 'news' => $page['news'], 'whos_online' => $page['whos_online']]); -} - -/** - * Staying at the inn resets all expendable stats to their max values. - * GET/POST /inn - */ -function inn() -{ - $town = get_town_by_xy(user()->longitude, user()->latitude); - if ($town === false) { exit('Cheat attempt detected.

Get a life, loser.'); } - - if (user()->gold < $town['innprice']) { - $page = <<
- You may return to town, or use the direction buttons on the left to start exploring. - HTML; - } elseif (is_post() && $_POST['rest']) { - user()->gold -= $town['innprice']; - user()->restore_points()->save(); - $page = <<
- You may return to town, or use the direction buttons on the left to start exploring. - HTML; - } elseif (is_post() && !$_POST['rest']) { - redirect('/'); - } else { - $page = <<
- A night's sleep at this Inn will cost you {$town['innprice']} gold. Is that ok?

-
- - -
- HTML; - } - - page_title($town['name'] . ' Inn'); - return $page; -} - -/** - * Displays a list of available items for purchase from the town the user is currently in. If the user is not in a town, - * redirects to home. - * GET /shop - */ -function shop() -{ - $town = get_town_by_xy(user()->longitude, user()->latitude); - if ($town === false) exit('Cheat attempt detected.

Get a life, loser.'); - - - $page = <<
- Click an item name to purchase it.

- The following items are available at this town:

- - HTML; - - $items = db()->query('SELECT * FROM items WHERE id IN (' . $town["itemslist"] . ');'); - while ($item = $items->fetchArray(SQLITE3_ASSOC)) { - $attrib = ($item["type"] == 1) ? "Attack Power:" : "Defense Power:"; - $page .= ''; - if (user()->weaponid === $item["id"] || user()->armorid === $item["id"] || user()->shieldid === $item["id"]) { - $page .= <<{$item["name"]} - - - HTML; - } else { - $specialdot = $item['special'] !== 'X' ? '*' : ''; - $page .= <<{$item['name']}$specialdot - - - HTML; - } - $page .= ''; - } - $page .= <<
- If you've changed your mind, you may also return back to town. - HTML; - - page_title($town['name'] . ' Shop'); - return $page; -} - -/** - * Confirm user's intent to purchase item. - */ -function buy(int $id) -{ - $town = get_town_by_xy(user()->longitude, user()->latitude); - if ($town === false) redirect('/'); - if (!in_array($id, explode(',', $town['itemslist']))) redirect('/shop'); - $item = get_item($id); - $can_afford = user()->gold >= $item['buycost']; - - if (!$can_afford) { - $page = <<{$item['name']}.

- You may return to town, shop, - or use the direction buttons on the left to start exploring. - HTML; - } elseif (is_post() && !$_POST['buy']) { - redirect('/shop'); - } elseif (is_post() && $_POST['buy']) { - $type_mapping = [ - 1 => ['id' => 'weaponid', 'name' => 'weaponname', 'power' => 'attackpower'], - 2 => ['id' => 'armorid', 'name' => 'armorname', 'power' => 'defensepower'], - 3 => ['id' => 'shieldid', 'name' => 'shieldname', 'power' => 'defensepower'] - ]; - - if (!isset($type_mapping[$item["type"]])) { // should never happen - return 'Error! Invalid item type...
'.var_dump($item); - } - - // Retrieve current equipped item or create a default - $current_equip_id = user()->{$type_mapping[$item["type"]]['id']}; - if ($current_equip_id != 0) { - $item2 = get_item($current_equip_id); - } else { - $item2 = ["attribute" => 0, "buycost" => 0, "special" => "X"]; - } - - // Process special item effects - $specialFields = []; - $specialValues = []; - $powerAdjustments = 0; - - foreach ([$item, $item2] as $index => $process_item) { - if ($process_item["special"] != "X") { - $special = explode(",", $process_item["special"]); - $toChange = $special[0]; - $changeAmount = $index === 0 ? $special[1] : -$special[1]; - - user()->$toChange += $changeAmount; - $specialFields[] = "$toChange = ?"; - $specialValues[] = user()->$toChange; - - // Adjust attack or defense power - if ($toChange == "strength" || $toChange == "dexterity") { - $powerType = $toChange == "strength" ? "attackpower" : "defensepower"; - $powerAdjustments += $changeAmount; - } - } - } - - // Determine power and type-specific updates - $currentType = $type_mapping[$item['type']]; - $powerField = $currentType['power']; - user()->$powerField += $item['attribute'] - $item2['attribute']; - - // Calculate new gold with trade-in value - user()->gold += ceil($item2['buycost'] / 2) - $item['buycost']; - - // Ensure current HP/MP/TP don't exceed max values - user()->currenthp = min(user()->currenthp, user()->maxhp); - user()->currentmp = min(user()->currentmp, user()->maxmp); - user()->currenttp = min(user()->currenttp, user()->maxtp); - - // Update item info in user - user()->{$type_mapping[$item['type']]['id']} = $item['id']; - user()->{$type_mapping[$item['type']]['name']} = $item['name']; - - user()->save(); - - $page = <<{$item['name']}.

- You may return to town, shop, or use the direction buttons on the - left to start exploring. - HTML; - } else { - $type_to_row_mapping = [1 => 'weaponid', 2 => 'armorid', 3 => 'shieldid']; - $current_equipped_id = user()->{$type_to_row_mapping[$item['type']]} ?? 0; - - if ($current_equipped_id != 0) { - $item2 = get_item($current_equipped_id); - $sell_price = ceil($item2['buycost'] / 2); - $page = <<
- - - - - HTML; - } else { - $page = <<
- - - - - HTML; - } - } - - page_title('Buying '.$item['name']); - return $page; -} - -/** - * List maps the user can buy. - */ -function maps() -{ - $page = <<
- Click a town name to purchase its map.

-
'; - $page .= match ($item["type"]) { - 1 => 'weapon', - 2 => 'armor', - 3 => 'shield' - }; - $page .= '$attrib {$item['attribute']}Already purchased$attrib {$item['attribute']}Price: {$item['buycost']} gold
- HTML; - - $mapped = explode(',', user()->towns); - $towns = db()->query('SELECT * FROM towns ORDER BY id;'); - while ($town = $towns->fetchArray(SQLITE3_ASSOC)) { - $latitude = ($town["latitude"] >= 0) ? $town["latitude"] . "N," : ($town["latitude"] * -1) . "S,"; - $longitude = ($town["longitude"] >= 0) ? $town["longitude"] . "E" : ($town["longitude"] * -1) . "W"; - - if (in_array($town['id'], $mapped)) { - $page .= << - - - - - - HTML; - } else { - $page .= << - - - - - HTML; - } - } - - $page .= <<
- If you've changed your mind, you may also return back to town. - HTML; - - page_title('Maps'); - return $page; -} - -function buy_map(int $id): string -{ - $town = get_town_by_id($id); - if ($town === false) redirect('/maps'); - - if (user()->gold < $town['mapprice']) { - $page = <<
- You may return to town, store, or use the direction buttons on the left to start exploring. - HTML; - } elseif (is_post() && $_POST['buy']) { - user()->towns .= ",$id"; - user()->gold -= $town['mapprice']; - user()->save(); - - $page = <<
- You may return to town, map shop, or use the direction buttons on the left to start exploring. - HTML; - } elseif (is_post() && !$_POST['buy']) { - redirect('/maps'); - } else { - $page = <<{$town['name']} map for {$town['mapprice']} gold. Is that ok?

- - - - - HTML; - } - - page_title('Buying '.$town['name'].' Map'); - return $page; -} - -/** - * Send a user to a town from the Travel To menu. - */ -function travelto(int $id, bool $use_points = true): string -{ - if (user()->currentaction == "Fighting") redirect('/fight'); - - $town = get_town_by_id($id); - $cost = $use_points ? $town['travelpoints'] : 0; - $mapped = explode(',', user()->towns); - $travelled = false; - - if ($use_points && !in_array($id, $mapped)) { - // trying to teleport to this town when it is not mapped - redirect('/'); - } elseif (user()->currenttp < $cost) { - $page = 'You do not have enough TP to travel here. Please go back and try again when you get more TP.'; - } elseif ((user()->latitude == $town['latitude']) && (user()->longitude == $town['longitude'])) { - if (!in_array($id, $mapped)) { - // add town to user's mapped if they travelled here - user()->towns .= ",$id"; - $travelled = true; - $page = <<{$town['name']}! It has been added to your mapped towns.

- You may now enter this town. - HTML; - } else { - $page = 'You are already in this town. Click here to return.'; - } - } else { - user()->latitude = $town['latitude']; - user()->longitude = $town['longitude']; - user()->currenttp -= $cost; - $travelled = true; - $page = 'You have travelled to '.$town['name'].'. You may now enter this town.'; - } - - if ($travelled) { - user()->currentaction = 'In Town'; - user()->save(); - } - - page_title('Travelling to '.$town['name']); - return $page; -} diff --git a/src/actions/users.php b/src/actions/users.php deleted file mode 100644 index 4b8b4b9..0000000 --- a/src/actions/users.php +++ /dev/null @@ -1,226 +0,0 @@ -form('/login', 'Users\login'); - $r->get('/logout', 'Users\logout'); - $r->form('/register', 'Users\register'); - $r->form('/lostpassword', 'Users\lostpassword'); - $r->post('/changepassword', 'Users\changepassword'); - $r->form('/verify', 'Users\verify'); - $r->form('/settings', 'Users\settings'); - return $r; -} - -/** - * Displays the login page, and processes login requests. - */ -function login() -{ - global $auth; - - if (is_post()) { - $form = validate($_POST, [ - 'username' => ['length:3-18', 'alpha-spaces'], - 'password' => ['length:6-255'] - ]); - - if (!$form['valid']) exit(ul_from_validate_errors($form['errors'])); - - $good = $auth->login($form['data']['username'], $form['data']['password']); - if (!$good) exit('Invalid username or password. Please go back and try again.'); - - redirect('/'); - } - - page_title('Login'); - return render('login'); -} - -/** - * Delete the current cookie and redirect to home. - */ -function logout() -{ - global $auth; - $auth->logout(); - redirect('/login'); -} - -/** - * Register a new account. - */ -function register() -{ - if (isset($_POST["submit"])) { - $form = validate($_POST, [ - 'username' => ['length:3-18', 'alpha-spaces', 'unique:users,username'], - 'email' => ['email', 'unique:users,email'], - 'confirm_email' => ['confirm'], - 'password' => ['length:6-255'], - 'confirm_password' => ['confirm'], - 'charclass' => ['in:1,2,3'] - ]); - - if (!$form['valid']) { - $err = ul_from_validate_errors($form['errors']); - $page = "The following error(s) occurred when your account was being made:
$err
Please go back and try again."; - } else { - $form = $form['data']; - $password = password_hash($form['password'], PASSWORD_ARGON2ID); - $token = env('verify_email') ? token(8) : 'g2g'; - db()->query('INSERT INTO users (verify, username, password, email, charclass) VALUES (?, ?, ?, ?, ?)', [ - $token, $form['username'], $password, $form['email'], $form['charclass'] - ]); - - if (env('verify_email')) { - if (sendregmail($form['email'], $token)) { - $page = "Your account was created successfully.

You should receive an Account Verification email shortly. You will need the verification code contained in that email before you are allowed to log in. Once you have received the email, please visit the Verification Page to enter your code and start playing."; - } else { - $page = "Your account was created successfully.

However, there was a problem sending your verification email. Please check with the game administrator to help resolve this problem."; - } - } else { - $page = "Your account was created succesfully.

You may now continue to the Login Page and continue playing ".env('game_name')."!"; - } - } - } else { - if (env('verify_email')) { - $verify_text = "
A verification code will be sent to the address above, and you will not be able to log in without first entering the code. Please be sure to enter your correct email address."; - } else { - $verify_text = ""; - } - - $page = render('register', ['verify_text' => $verify_text]); - } - - page_title('Register'); - return $page; -} - -function verify() -{ - if (isset($_POST["submit"])) { - $u = trim($_POST['username'] ?? ''); - $e = trim($_POST['email'] ?? ''); - $t = trim($_POST['token'] ?? ''); - - $query = db()->query('SELECT id FROM users WHERE username=? AND email=? AND verify=? LIMIT 1;', [$u, $e, $t]); - if ($query === false) exit('Verification failed. Go back, double-check your details, and try again.'); - - db()->query("UPDATE users SET verify='g2g' WHERE username=?;", [$u]); - - return "Your account was verified successfully.

You may now continue to the Login Page and start playing the game.

Thanks for playing!"; - } - - return render('verify'); -} - -function lostpassword() -{ - if (isset($_POST["submit"])) { - $e = trim($_POST['email'] ?? ''); - - if (!db()->exists('users', 'email', $e)) exit("No account with that email address."); - - $newpass = token(16); - $hashed = password_hash($newpass, PASSWORD_ARGON2ID); - - db()->query('UPDATE users SET password=? WHERE email=?;', [$hashed, $e]); - - if (sendpassemail($e, $newpass)) { - return "Your new password was emailed to the address you provided.

Once you receive it, you may Log In and continue playing.

Thank you."; - } else { - return "There was an error sending your new password.

Please check with the game administrator for more information.

We apologize for the inconvience."; - } - } - - return render('lostpassword'); -} - -function changepassword() -{ - global $auth; - - if (isset($_POST["submit"])) { - $u = trim($_POST['username'] ?? ''); - $p = $_POST['password'] ?? ''; - $np = $_POST['new_password'] ?? ''; - $np2 = $_POST['new_password2'] ?? ''; - - $user = db()->query("SELECT password FROM users WHERE username=? LIMIT 1;", [$u]); - $user = $user->fetchArray(SQLITE3_ASSOC); - if ($user === false) exit("No account with that username."); - - if (!password_verify($p, $user['password'])) exit("The old password you provided was incorrect."); - - if (empty($np) || strlen($np) < 6) { - $errors[] = 'New password is required and must be at least 6 characters long.'; - } - - if ($np2 !== $np) { - $errors[] = 'Verify New Password must match.'; - } - - $realnewpass = password_hash($np, PASSWORD_ARGON2ID); - db()->query('UPDATE users SET password=? WHERE username=?;', [$realnewpass, $u]); - - $auth->logout(); - - return "Your password was changed successfully.

You have been logged out of the game to avoid errors.

Please log back in to continue playing."; - } -} - -function settings() -{ - if (is_post()) { - $form = validate($_POST, [ - 'game_skin' => ['in:0,1'] - ]); - if (!$form['valid']) exit(ul_from_validate_errors($form['errors'])); - $form = $form['data']; - - user()->game_skin = $form['game_skin']; - user()->save(); - - $alert = '
Settings updated
'; - return $alert . render('settings'); - } - - return render('settings'); -} - -function sendpassemail($emailaddress, $password) -{ - $email = <<good()) { - // Block user if he/she has been banned. - if (user()->authlevel === 2) { - exit("Your account has been banned."); - } - - // Force verify if the user isn't verified yet. - if (env('verify_email') && user()->verify !== 'g2g' && !in_array($uri[0], ['verify', 'logout'])) { - redirect('/verify'); - } - - // Ensure the user can't use the admin panel. - if (user()->authlevel !== 1 && $uri[0] === 'admin') { - redirect('/'); - } - - // Update default page layout based on root endpoint - page_layout('layouts/primary'); - if ($uri[0] === 'admin') page_layout('layouts/admin'); - if ($uri[0] === 'help') page_layout('layouts/help'); - - user()->update_online_time(); - } else { - $auth->logout(); - redirect('/login'); - } -} diff --git a/src/lib.php b/src/lib.php deleted file mode 100644 index b6e6f97..0000000 --- a/src/lib.php +++ /dev/null @@ -1,546 +0,0 @@ - $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 - ]); - - 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 = '
    '; - foreach ($errors as $field => $errors) { - $string .= '
  • '; - foreach ($errors as $error) $string .= $error; - $string .= '
  • '; - } - return $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'); -} - -/** - * 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 .= ''.page_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; -} diff --git a/src/mail.php b/src/mail.php deleted file mode 100644 index b955aa0..0000000 --- a/src/mail.php +++ /dev/null @@ -1,109 +0,0 @@ - env('admin_email', 'noreply@'.$_SERVER['SERVER_NAME']), - 'log_path' => '../logs/email.log', - 'method' => 'smtp', // 'smtp' or 'log' - 'smtp_host' => env('smtp_host', 'localhost'), - 'smtp_port' => env('smtp_port', 25), - 'smtp_username' => env('smtp_username', null), - 'smtp_password' => env('smtp_password', null), - 'smtp_encryption' => env('smtp_encryption', null) - ], $options); - - // Always send to log during debug - if (env('debug', false)) $config['method'] = 'log'; - - // Validate input - if (empty($to) || empty($subject) || empty($message)) { - error_log('Email sending failed: Missing required parameters'); - return false; - } - - // Prepare email headers - $headers = [ - 'From: ' . $config['from'], - 'X-Mailer: PHP/' . phpversion() - ]; - - // Choose sending method - switch ($config['method']) { - case 'log': - // Log email details to file - $logMessage = sprintf( - "[%s] To: %s, Subject: %s, Message:\n\n %s\n\n\n\n", - date('Y-m-d H:i:s'), - $to, - $subject, - $message - ); - - // Attempt to log to file - if (file_put_contents($config['log_path'], $logMessage, FILE_APPEND) === false) { - error_log('Failed to write to log file: ' . $config['log_path']); - return false; - } - return true; - - case 'smtp': - default: - // Attempt to send via SMTP - try { - // Prepare SMTP connection - $smtpConfig = [ - 'host' => $config['smtp_host'], - 'port' => $config['smtp_port'], - 'username' => $config['smtp_username'], - 'password' => $config['smtp_password'], - 'encryption' => $config['smtp_encryption'] - ]; - - // Send email using PHP's mail function (basic SMTP) - $result = mail( - $to, - $subject, - $message, - implode("\r\n", $headers) - ); - - if (!$result) { - error_log('SMTP email sending failed'); - return false; - } - - return true; - } catch (Exception $e) { - error_log('Email sending error: ' . $e->getMessage()); - return false; - } - } -} - -// Example usage: -// Send via SMTP -// send_email('recipient@example.com', 'Test Subject', 'Email body text'); - -// Send via log -// send_email('recipient@example.com', 'Test Subject', 'Email body text', ['method' => 'log']); - -// Customize SMTP settings -// send_email('recipient@example.com', 'Test Subject', 'Email body text', [ -// 'method' => 'smtp', -// 'smtp_host' => 'smtp.yourserver.com', -// 'smtp_port' => 587, -// 'smtp_username' => 'your_username', -// 'smtp_password' => 'your_password', -// 'smtp_encryption' => 'tls' -// ]); diff --git a/src/math.php b/src/math.php deleted file mode 100644 index aea7849..0000000 --- a/src/math.php +++ /dev/null @@ -1,98 +0,0 @@ - 5) throw new \InvalidArgumentException("Growth rate must be between 0 and 5"); - - return match($growth_rate) { - 0 => calculate_erratic_exp($level), - 1 => (4 * pow($level, 3)) / 5, - 2 => pow($level, 3), - 3 => ((6 * pow($level, 3)) / 5) - (15 * pow($level, 2)) + (100 * $level) - 140, - 4 => (5 * pow($level, 3)) / 4, - 5 => calculate_fluctuating_exp($level), - }; -} - -/** - * Calculate the ***total*** EXP for a given level in the Erratic formula. - */ -function calculate_erratic_exp(int $level): int -{ - if ($level <= 50) { - return (pow($level, 3) * (100 - $level)) / 50; - } elseif ($level <= 68) { - return (pow($level, 3) * (150 - $level)) / 100; - } elseif ($level <= 98) { - return (pow($level, 3) * ((1911 - (10 * $level)) / 3)) / 500; - } else { - return (pow($level, 3) * (160 - $level)) / 100; - } -} - -/** - * Calculate the ***total*** EXP for a given level in the Fluctuating formula. - */ -function calculate_fluctuating_exp(int $level): int -{ - if ($level <= 15) { - return pow($level, 3) * ((((($level + 1) / 3) + 24) / 50)); - } elseif ($level <= 36) { - return pow($level, 3) * (($level + 14) / 50); - } else { - return pow($level, 3) * ((($level / 2) + 32) / 50); - } -} - -/** - * Calculate a points total from a base. Modes: 1 (weak), 2 (normal), 3 (strong) - */ -function calculate_points(int $base_points, int $level, int $mode = 2): int -{ - if ($level < 1) throw new \InvalidArgumentException("Level must be 1 or greater"); - - $growth_multiplier = match($mode) { - 1 => 0.15, - 2 => 0.3, - 3 => 0.6, - default => throw new \InvalidArgumentException("Invalid mode. Use 1 (weak), 2 (normal), or 3 (strong)") - }; - - return floor((2 * $base_points * $level * $growth_multiplier) / 100) + $level + 10; -} - -/** - * Calculate a stat total from a base. Modes: 1 (weak), 2 (normal), 3 (strong) - */ -function calculate_stat(int $base_stat, int $level, int $mode = 2): int -{ - if ($level < 1) throw new \InvalidArgumentException("Level must be 1 or greater"); - - $growth_multiplier = match($mode) { - 1 => 0.15, - 2 => 0.3, - 3 => 0.6, - default => throw new \InvalidArgumentException("Invalid mode. Use 1 (weak), 2 (normal), or 3 (strong)") - }; - - return floor((2 * $base_stat * $level * $growth_multiplier) / 100) + 5; -} diff --git a/src/models/model.php b/src/models/model.php deleted file mode 100644 index 1e1dd13..0000000 --- a/src/models/model.php +++ /dev/null @@ -1,46 +0,0 @@ -original_data = $data; - $this->changes = []; - } - - public function __get(string $key): mixed - { - return array_key_exists($key, $this->changes) ? $this->changes[$key] : $this->original_data[$key] ?? false; - } - - public function __set(string $key, mixed $value): void - { - if (array_key_exists($key, $this->original_data)) { - if ($value !== $this->original_data[$key]) $this->changes[$key] = $value; - } else { - throw new InvalidArgumentException("Attempted to write to $key, which doesn't exist in the data for this model."); - } - } - - public function save(): bool - { - if (empty($this->changes)) return true; - - $placeholders = []; - $values = []; - foreach ($this->changes as $key => $value) { - $placeholders[] = "$key=?"; - $values[] = $value; - } - - $values[] = $this->id; - $query = 'UPDATE ' . $this->table_name . ' SET ' . implode(', ', $placeholders) . ' WHERE id = ?;'; - - $result = db()->query($query, $values); - return $result === false ? false : true; - } -} diff --git a/src/models/user.php b/src/models/user.php deleted file mode 100644 index 8356f7d..0000000 --- a/src/models/user.php +++ /dev/null @@ -1,85 +0,0 @@ -query( - "SELECT * FROM users WHERE id=? OR username=? COLLATE NOCASE OR email=? COLLATE NOCASE LIMIT 1;", - [$id, $id, $id] - ); - if ($query === false) return false; - $data = $query->fetchArray(SQLITE3_ASSOC); - if ($data === false) return false; - return new User($data); - } - - /** - * Return a list of spells from this user's spell list. - */ - public function spells(): array|false - { - return get_spells_from_list($this->spells); - } - - /** - * Restore all HP, MP, and TP values to their max. - */ - public function restore_points(): User - { - $this->currenthp = $this->maxhp; - $this->currentmp = $this->maxmp; - $this->currenttp = $this->maxtp; - return $this; - } - - /** - * Sends a manual update to online time for this user. - */ - public function update_online_time(): void - { - if ($this->onlinetime && strtotime($this->onlinetime) > strtotime('-9 minutes')) return; - db()->query('UPDATE users SET onlinetime=CURRENT_TIMESTAMP WHERE id=?;', [$this->id]); - } - - /** - * Heal HP by a given amount. Caps to max HP. Returns number of points restored. - */ - function restore_hp(int $amount): int - { - $initial_hp = $this->currenthp; - $this->currenthp += $amount; - if ($this->currenthp > $this->maxhp) $this->currenthp = $this->maxhp; - return $this->currenthp - $initial_hp; - } - - /** - * Save works just as it does on the Model class. In our case, though, user state changing may necessitate - * OOB swaps for parts of the UI that have user data displayed. Left and right nav, for example. In these cases, - * we set a flag in GLOBALS state to signify this. - */ - public function save(): bool - { - if (empty($this->changes)) return true; - - $placeholders = []; - $values = []; - foreach ($this->changes as $key => $value) { - $placeholders[] = "$key=?"; - $values[] = $value; - } - - $values[] = $this->id; - $query = 'UPDATE ' . $this->table_name . ' SET ' . implode(', ', $placeholders) . ' WHERE id = ?;'; - - $result = db()->query($query, $values); - if ($result === false) return false; - $GLOBALS['state']['user-state-changed'] = true; - return true; - } -} diff --git a/src/render.php b/src/render.php deleted file mode 100644 index ac63bfb..0000000 --- a/src/render.php +++ /dev/null @@ -1,62 +0,0 @@ - $content]); -} - -function debug_db_info(): string { - $total_time = round(microtime(true) - START, 4); - $htmx = is_htmx() ? ' (htmx)' : ''; - return '
'. $total_time . ' Seconds, ' . db()->count . ' Queries'.$htmx.'
'; -} - -function right_nav(): string -{ - if (user() === false) return ''; - - // Flashy numbers if they're low - $hp = (user()->currenthp <= (user()->maxhp / 5)) ? "*" . user()->currenthp . "*" : user()->currenthp; - $mp = (user()->currentmp <= (user()->maxmp / 5)) ? "*" . user()->currentmp . "*" : user()->currentmp; - - $template = render('right_nav', ['hp' => $hp, 'mp' => $mp]); - if (is_htmx()) $template = '"; - return $template; -} - -function left_nav(): string -{ - if (user() === false) return ''; - - $template = render('left_nav'); - if (is_htmx()) $template = '
'.$template."
"; - return $template; -} - -function babblebox(): string -{ - return render('babblebox', ['messages' => babblebox_messages()]); -} - -function debug_query_log(): string -{ - $html = '
';
-	foreach (db()->log as $record) {
-		$query_string = str_replace(["\r\n", "\n", "\r"], ' ', $record[0]);
-		$error_string = !empty($record[2]) ? '// '.$record[2] : '';
-		$html .= '
['.round($record[1], 2)."s] {$query_string}{$error_string}
"; - } - return $html . '
'; -} diff --git a/src/router.php b/src/router.php deleted file mode 100644 index ca008d1..0000000 --- a/src/router.php +++ /dev/null @@ -1,187 +0,0 @@ -add($routes, 'GET', '/posts/:id', function($id) { echo "Viewing post $id"; });` - */ - public function add(string $method, string $route, callable $handler): Router - { - $this->validateMethod($method); - $this->validateRoute($route); - - $segments = $route === '/' ? [''] : explode('/', trim($route, '/')); - - $node = &$this->routes; - foreach ($segments as $segment) { - $segment = str_starts_with($segment, ':') ? ':x' : $segment; - if ($segment === '') continue; - $node = &$node[$segment]; - } - - $node[$method] = [ - 'handler' => $handler, - 'middleware' => [] - ]; - - $this->last_inserted_node = &$node[$method]; - - return $this; - } - - /** - * Perform a lookup in the route tree for a given method and URI. Returns an array with a result code, - * a handler if found, and any dynamic parameters. Codes are 200 for success, 404 for not found, and - * 405 for method not allowed. - * - * @return array ['code', 'handler', 'params'] - */ - public function lookup(string $method, string $uri): array|int - { - $node = $this->routes; - $params = []; - - if ($uri === '/') return $node[$method] ?? 405; - - foreach (explode('/', trim($uri, '/')) as $segment) { - if (isset($node[$segment])) { - $node = $node[$segment]; - continue; - } - - if (isset($node[':x'])) { - $params[] = $segment; - $node = $node[':x']; - continue; - } - - return 404; - } - - $node[$method]['params'] = $params; - return $node[$method] ?? 405; - } - - /** - * Add a middleware function to the last inserted node's stack. - */ - public function middleware(callable $middleware): Router - { - $this->last_inserted_node['middleware'][] = $middleware; - return $this; - } - - /** - * Shorthand to register a GET route. - */ - public function get(string $route, callable $handler): Router - { - return $this->add('GET', $route, $handler); - } - - /** - * Shorthand to register a POST route. - */ - public function post(string $route, callable $handler): Router - { - return $this->add('POST', $route, $handler); - } - - /** - * Shorthand to register a PUT route. - */ - public function put(string $route, callable $handler): Router - { - return $this->add('PUT', $route, $handler); - } - - /** - * Shorthand to register a DELETE route. - */ - public function delete(string $route, callable $handler): Router - { - return $this->add('DELETE', $route, $handler); - } - - /** - * Shorthand to register a PATCH route. - */ - public function patch(string $route, callable $handler): Router - { - return $this->add('PATCH', $route, $handler); - } - - /** - * Register multiple verbs to the same route. - */ - public function many(array $methods, string $route, callable $handler): Router - { - foreach ($methods as $method) $this->add($method, $route, $handler); - return $this; - } - - /** - * Register all verbs to the same route. - */ - public function any(string $route, callable $handler): Router - { - foreach (SELF::VALID_METHODS as $method) $this->add($method, $route, $handler); - return $this; - } - - /** - * Some pages function entirely as forms; thus we can shorthand a GET/POST route. - */ - public function form(string $route, callable $handler): Router - { - return $this->many(['GET', 'POST'], $route, $handler); - } - - /** - * Validate the given method against valid HTTP verbs. - */ - private function validateMethod(string $method): void - { - if (!in_array($method, self::VALID_METHODS)) { - throw new InvalidArgumentException("Invalid HTTP method: $method"); - } - } - - /** - * Validate that a new route follows expected formatting. - */ - private function validateRoute(string $route): void - { - if ($route === '') { - throw new InvalidArgumentException("Route cannot be empty"); - } - - // Ensure route starts with a slash - if (!str_starts_with($route, '/')) { - throw new InvalidArgumentException("Route must start with a '/'"); - } - } -} diff --git a/templates/admin/edit_drop.php b/templates/admin/edit_drop.php index 6946753..b24f4e0 100644 --- a/templates/admin/edit_drop.php +++ b/templates/admin/edit_drop.php @@ -1,19 +1,19 @@ -

Editing

- +

Editing

+
{$town['name']}Already mapped.Location: $latitude $longitudeTP: {$town['travelpoints']}
{$town['name']}Price: {$town['mapprice']} goldBuy map to reveal details.
- - + + @@ -21,7 +21,7 @@
ID:
Name:
ID:
Name:
Monster Level: -
+
Minimum monster level that will drop this item.
Attribute 1: -
+
Must be a special code. First attribute cannot be disabled. Edit this field very carefully because mistakes to formatting or field names can create problems in the game.
Attribute 2: -
+
Should be either a special code or X to disable. Edit this field very carefully because mistakes to formatting or field names can create problems in the game. diff --git a/templates/admin/edit_item.php b/templates/admin/edit_item.php index 708fee5..41c840c 100644 --- a/templates/admin/edit_item.php +++ b/templates/admin/edit_item.php @@ -1,34 +1,34 @@ -

Edit

- +

Edit

+ - + - + - + diff --git a/templates/admin/edit_level.php b/templates/admin/edit_level.php index 0805ba5..22b63d5 100644 --- a/templates/admin/edit_level.php +++ b/templates/admin/edit_level.php @@ -1,29 +1,29 @@ -

Edit Level

+

Edit Level

Experience values for each level should be the cumulative total amount of experience up to this point. All other values should be only the new amount to add this level.

- +
ID:
ID:
Name:
Type:
Cost: gold gold
Attribute: -
+
How much the item adds to total attackpower (weapons) or defensepower (armor/shields).
Special: -
+
Should be either a special code or X to disable. Edit this field very carefully because mistakes to formatting or field names can create problems in the game.
- + - - - - - - - - - ' : '' ?> + + + + + + + + + ' : '' ?>
ID:
EXP ">
HP ">
MP ">
TP ">
Strength ">
Dexterity">
Spells ">
EXP ">
HP ">
MP ">
TP ">
Strength ">
Dexterity">
Spells ">
diff --git a/templates/admin/edit_monster.php b/templates/admin/edit_monster.php index 7d3e7cc..70c1cb5 100644 --- a/templates/admin/edit_monster.php +++ b/templates/admin/edit_monster.php @@ -1,56 +1,56 @@ -

Edit

+

Edit

- - + - + - + @@ -58,9 +58,9 @@ - + diff --git a/templates/right_nav.php b/templates/right_nav.php index 4454fb7..571ee1d 100644 --- a/templates/right_nav.php +++ b/templates/right_nav.php @@ -1,39 +1,41 @@
Character
- username ?>
- Level: level ?>
- Exp: experience) ?>
- Gold: gold) ?>
- HP:
- MP:
- TP: currenttp ?>

-
+ username ?>
+ Level: level ?>
+ Exp: experience) ?>
+ Gold: gold) ?>
+ HP:
+ MP:
+ TP: currenttp ?>

+
Extended Stats
Inventory
- Weapon weaponname ?>
- Armor armorname ?>
- Shield shieldname ?>
- Slot 1: slot1name ?>
- Slot 2: slot2name ?>
- Slot 3: slot3name ?> + Weapon weaponname ?>
+ Armor armorname ?>
+ Shield shieldname ?>
+ Slot 1: slot1name ?>
+ Slot 2: slot2name ?>
+ Slot 3: slot3name ?>
Fast Spells
spells(); - if ($user_spells !== false) { - foreach ($user_spells as $spell) { - // list only healing spells for now - if ($spell['type'] === 1) echo <<spells(); + if ($user_spells !== false) { + foreach ($user_spells as $spell) { + // list only healing spells for now + if ($spell['type'] === 1) { + echo <<{$spell['name']}
HTML; - } - } else { - echo 'None'; - } + } + } + } else { + echo 'None'; + } ?>
diff --git a/templates/show_char.php b/templates/show_char.php index 89e4f9a..6b3a2eb 100644 --- a/templates/show_char.php +++ b/templates/show_char.php @@ -1,36 +1,38 @@
Character
-username ?>

+username ?>

-Class: charclass) { - 1 => env('class_1_name'), - 2 => env('class_2_name'), - 3 => env('class_3_name') +Class: charclass) { + 1 => DragonKnight\Lib::env('class_1_name'), + 2 => DragonKnight\Lib::env('class_2_name'), + 3 => DragonKnight\Lib::env('class_3_name') }; ?>

-Level: level ?>
-Experience: experience) ?> - expbonus !== 0): ?> (expbonus > 0 ? '+' : '' ?>) expbonus ?>%)
-Next Level: level < 99) { echo number_format($level[$char->charclass."_exp"]); } else { ?> None
-Gold: gold) ?> - goldbonus !== 0): ?> (goldbonus > 0 ? '+' : '' ?>) goldbonus ?>%)
-Hit Points: currenthp) ?> / maxhp) ?>
-Magic Points: currentmp) ?> / maxmp) ?>
-Travel Points: currenttp) ?> / maxtp) ?>

+Level: level ?>
+Experience: experience) ?> + expbonus !== 0): ?> (expbonus > 0 ? '+' : '' ?>) expbonus ?>%)
+Next Level: level < 99) { + echo number_format($level[$char->charclass.'_exp']); +} else { ?> None
+Gold: gold) ?> + goldbonus !== 0): ?> (goldbonus > 0 ? '+' : '' ?>) goldbonus ?>%)
+Hit Points: currenthp) ?> / maxhp) ?>
+Magic Points: currentmp) ?> / maxmp) ?>
+Travel Points: currenttp) ?> / maxtp) ?>

-Strength: strength) ?>
-Dexterity: dexterity) ?>
-Attack Power: attackpower) ?>
-Defense Power: defensepower) ?>
+Strength: strength) ?>
+Dexterity: dexterity) ?>
+Attack Power: attackpower) ?>
+Defense Power: defensepower) ?>

Inventory
-Weaponweaponname ?>
-Armorarmorname ?>
-Shieldshieldname ?>
-Slot 1: slot1name ?>
-Slot 2: slot2name ?>
-Slot 3: slot3name ?> +Weaponweaponname ?>
+Armorarmorname ?>
+Shieldshieldname ?>
+Slot 1: slot1name ?>
+Slot 2: slot2name ?>
+Slot 3: slot3name ?>
Spells
- + diff --git a/templates/towns.php b/templates/towns.php index 63ff941..33f58c7 100644 --- a/templates/towns.php +++ b/templates/towns.php @@ -1,6 +1,6 @@
-
Welcome to <?= $town['name'] ?>
+
Welcome to <?php echo $town['name'] ?>
Town Options:
- +
- +
Babblebox
- +
ID
Name
Max HP
Max Damage -
+
Compares to player's attack power.
Armor -
+
Compares to player's defense power.
Monster Level -
+
Determines spawn location and item drops.
Max EXP -
+
Max experience gained from defeating monster.
Max Gold -
+
Max gold gained from defeating monster.
Immunity
Some monsters may not be hurt by certain spells. diff --git a/templates/admin/edit_spell.php b/templates/admin/edit_spell.php index cdc75b3..4519948 100644 --- a/templates/admin/edit_spell.php +++ b/templates/admin/edit_spell.php @@ -1,21 +1,21 @@ -

Edit

+

Edit

- + - + - + @@ -23,7 +23,7 @@ @@ -32,11 +32,11 @@ @@ -42,7 +42,7 @@ @@ -65,8 +65,8 @@ @@ -75,8 +75,8 @@ @@ -85,23 +85,23 @@ - + - + - +
ID
Name
MP - +
MP required to cast spell.
Attribute - +
Numeric value of the spell's effect. Ties with type, below.
Type
diff --git a/templates/admin/edit_town.php b/templates/admin/edit_town.php index eb2b864..e95643d 100644 --- a/templates/admin/edit_town.php +++ b/templates/admin/edit_town.php @@ -1,51 +1,51 @@ -

Edit

- +

Edit

+ - + - + - + diff --git a/templates/admin/edit_user.php b/templates/admin/edit_user.php index a937b7e..b920a87 100644 --- a/templates/admin/edit_user.php +++ b/templates/admin/edit_user.php @@ -1,86 +1,86 @@ -

Edit

- +

Edit

+
ID ID
Name
Latitude -
+
Positive or negative integer.
Longitude -
+
Positive or negative integer.
Inn Price gold gold
Map Price - gold
+ gold
How much it costs to buy the map to this town.
Travel Points -
+
How many TP are consumed when travelling to this town.
Items List -
+
Comma-separated list of item ID numbers available for purchase at this town. (Example: 1,2,3,6,9,10,13,20)
- - - - - + + + + + - - + + - - - - - - - - + + + + + + + + - - - - - - + + + + + + - - - - - - - - - + + + + + + + + + - - - - - - - - - - - - + + + + + + + + + + + + - - - + + +
ID
Username
Email
Register Date
Last Online
ID
Username
Email
Register Date
Last Online
Auth Level
Set to "Blocked" to temporarily (or permanently) ban a user.
Latitude
Longitude
Latitude
Longitude
Character Class
Current Action
Current Fight
Current Monster
Current Monster HP
Current Monster Sleep
Current Monster Immune
Current Uber Damage
Current Uber Defense
Current Action
Current Fight
Current Monster
Current Monster HP
Current Monster Sleep
Current Monster Immune
Current Uber Damage
Current Uber Defense
Current HP
Current MP
Current TP
Max HP
Max MP
Max TP
Current HP
Current MP
Current TP
Max HP
Max MP
Max TP
Level
Gold
Experience
Gold Bonus
EXP Bonus
Strength
Dexterity
Attack Power
Defense Power
Level
Gold
Experience
Gold Bonus
EXP Bonus
Strength
Dexterity
Attack Power
Defense Power
Weapon ID
Armor ID
Shield ID
Slot 1 ID
Slot 2 ID
Slot 3 ID
Weapon Name
Armor Name
Shield Name
Slot 1 Name
Slot 2 Name
Slot 3 Name
Weapon ID
Armor ID
Shield ID
Slot 1 ID
Slot 2 ID
Slot 3 ID
Weapon Name
Armor Name
Shield Name
Slot 1 Name
Slot 2 Name
Slot 3 Name
Drop Code
Spells
Towns
Drop Code
Spells
Towns
diff --git a/templates/admin/main_settings.php b/templates/admin/main_settings.php index 6351c23..202ab88 100644 --- a/templates/admin/main_settings.php +++ b/templates/admin/main_settings.php @@ -8,8 +8,8 @@
Game Open:
Close the game if you are upgrading or working on settings and don't want to cause odd errors for end-users. Closing the game will completely halt all activity. @@ -18,14 +18,14 @@
Game Name: -
+
Change this if you want to change to call your game something different.
Game URL: -
+
Please specify the full URL to your game installation ("https://www.dragonknight.com/"). This gets used in the registration email sent to users. If you leave this field blank or incorrect, users may not be able to register correctly. @@ -34,7 +34,7 @@
Admin Email: -
+
Please specify your email address. This gets used when the game has to send an email to users.
Map Size: -
+
Default is 250. This is the size of each map quadrant. Note that monster levels increase every 5 spaces, so you should ensure that you have at least (map size / 5) @@ -55,8 +55,8 @@
Email Verification:
Make users verify their email address for added security.
Show News:
Toggle display of the Latest News box in towns.
Show Who's Online:
Toggle display of the Who's Online box in towns.
Show Babblebox:
Toggle display of the Babble Box in towns.
Class 1 Name:

Class 2 Name:

Class 3 Name:

diff --git a/templates/babblebox.php b/templates/babblebox.php index 8520891..0f75231 100644 --- a/templates/babblebox.php +++ b/templates/babblebox.php @@ -1,6 +1,6 @@
- +
diff --git a/templates/fight.php b/templates/fight.php index 5c3b32e..318ca9c 100644 --- a/templates/fight.php +++ b/templates/fight.php @@ -1,10 +1,10 @@
Fighting
- You are fighting a

- - - - + You are fighting a

+ + + +
diff --git a/templates/layouts/admin.php b/templates/layouts/admin.php index e9d19e3..aaed0e2 100644 --- a/templates/layouts/admin.php +++ b/templates/layouts/admin.php @@ -3,14 +3,14 @@ - <?= page_title() ?> + <?php echo DragonKnight\Lib::page_title() ?>
-

+

Admin

@@ -33,18 +33,20 @@
- +
Powered by Dragon Knight
© 2024 Sharkk
- -
Version
+ +
Version
- +
diff --git a/templates/layouts/help.php b/templates/layouts/help.php index b140eca..3d79544 100644 --- a/templates/layouts/help.php +++ b/templates/layouts/help.php @@ -3,25 +3,25 @@ - <?= env('game_name', 'Dragon Knight') ?> Help + <?php echo DragonKnight\Lib::env('game_name', 'Dragon Knight') ?> Help -

Help

+

Help

[ Back to Help ]
[ Return to Game ]


- +

diff --git a/templates/layouts/minimal.php b/templates/layouts/minimal.php index 237f937..43cb44e 100644 --- a/templates/layouts/minimal.php +++ b/templates/layouts/minimal.php @@ -2,10 +2,10 @@ - <?= $title ?> + <?php echo $title ?> - + diff --git a/templates/layouts/primary.php b/templates/layouts/primary.php index 5e26e90..54c1862 100644 --- a/templates/layouts/primary.php +++ b/templates/layouts/primary.php @@ -3,7 +3,7 @@ - <?= page_title() ?> + <?php echo DragonKnight\Lib::page_title() ?> @@ -20,12 +20,12 @@ } - +
- +
-
-
- +
+
+
Powered by Dragon Knight
© 2024 Sharkk
- -
Version
+ +
Version
- +
diff --git a/templates/left_nav.php b/templates/left_nav.php index e3f559e..84012fe 100644 --- a/templates/left_nav.php +++ b/templates/left_nav.php @@ -1,14 +1,22 @@
Location
- Currently: currentaction ?>
+ Currently: currentaction ?>
latitude; - $lon = user()->longitude; - if ($lat < 0) { $lat = ($lat * -1) . "S"; } else { $lat .= "N"; } - if ($lon < 0) { $lon = ($lon * -1) . "W"; } else { $lon .= "E"; } + $lat = DragonKnight\Lib::user()->latitude; + $lon = DragonKnight\Lib::user()->longitude; + if ($lat < 0) { + $lat = ($lat * -1).'S'; + } else { + $lat .= 'N'; + } + if ($lon < 0) { + $lon = ($lon * -1).'W'; + } else { + $lon .= 'E'; + } ?> - Latitude:
- Longitude:
+ Latitude:
+ Longitude:
View Map
@@ -23,25 +31,27 @@
Towns
currentaction == 'In Town') { - $town = get_town_by_xy((int) user()->latitude, (int) user()->longitude); - echo "Welcome to {$town['name']}.

"; + if (DragonKnight\Lib::user()->currentaction == 'In Town') { + $town = DragonKnight\Lib::get_town_by_xy((int) DragonKnight\Lib::user()->latitude, (int) DragonKnight\Lib::user()->longitude); + echo "Welcome to {$town['name']}.

"; } ?> Travel To:
towns); - $towns = db()->query('SELECT * FROM towns ORDER BY id;'); + $town_list = explode(',', DragonKnight\Lib::user()->towns); + $towns = DragonKnight\Lib::db()->query('SELECT * FROM towns ORDER BY id;'); $mapped = false; while ($row = $towns->fetchArray(SQLITE3_ASSOC)) { - $mapped = true; - if (in_array($row['id'], $town_list)) { - echo <<{$row['name']}
HTML; - } + } + } + if (! $mapped) { + echo 'You have no towns mapped.'; } - if (!$mapped) echo 'You have no towns mapped.'; ?>
@@ -50,7 +60,7 @@ Home
Forum
Settings
- authlevel === 1): ?> + authlevel === 1): ?> Admin
Help
diff --git a/templates/register.php b/templates/register.php index 03d6e50..5a981a0 100644 --- a/templates/register.php +++ b/templates/register.php @@ -4,14 +4,14 @@
Password:
Verify Password:
Passwords must be 10 alphanumeric characters or less.


Email Address:
Verify Email:


Verify Email:


Character Class: