2025-08-14 13:55:37 -04:00

604 lines
20 KiB
PHP

<?php
declare(strict_types=1);
/*
* This file is a part of the Dragon-Knight project.
*
* Copyright (c) 2024-present Sharkk
*
* This file is subject to the MIT license that is bundled
* with this source code in the LICENSE.md file.
*/
namespace DragonKnight\Admin;
use DragonKnight\Lib;
use DragonKnight\Router;
use SQLite3Result;
class Admin
{
public static function register_routes(Router $r): Router
{
if (Lib::user() !== false && Lib::user()->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 <<<HTML
Welcome to the administration section. Use the links on the left bar to control and edit various
elements of the game.
<br><br>
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 <a href="https://sqlitebrowser.org/" target="_new">SQLite Browser</a>.
<br><br>
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 = <<<HTML
<b>Errors:</b><br>
<div style="color: red;">{$error_list}</div><br>
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 = "<h2>Edit Items</h2>Click an item's name or ID to edit it.<br><br>\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 = "<h2>Edit Drops</h2>Click an item's name to edit it.<br><br>\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 = "<h2>Edit Towns</h2>Click an town's name or ID to edit it.<br><br>\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 = '<h2>Edit Monsters</h2>';
$page .= ((Lib::env('game_size') / 5) !== $max_level)
? '<span class="highlight">Note:</span> 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.<br>"
: 'Monster level and map size match. No further actions are required for map compatibility.<br>';
$page .= "Click an monster's name or ID to edit it.<br><br>\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 = "<h2>Edit Spells</h2>Click an spell's name to edit it.<br><br>\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 = <<<HTML
<h2>Edit Levels</h2>
Select a level number from the dropdown box to edit it.<br><br>
<form action="/admin/levels" method="post" hx-post="/admin/levels" hx-target="#main">
<select name="level">
HTML;
for ($i = 2; $i < $max_level; $i++) {
$page .= "<option value=\"$i\">$i</option>\n";
}
$page .= <<<HTML
</select>
<button type="submit">Edit</button>
</form>
HTML;
Lib::page_title('Admin: Levels');
return $page;
}
/**
* Handle the editing of a level.
*/
public static function edit_level()
{
if (! isset($_POST['level'])) {
return 'No level to edit.';
}
$id = $_POST['level'];
$level = Lib::db()->query('SELECT * FROM levels WHERE id=? LIMIT 1;', [$id])->fetchArray(SQLITE3_ASSOC);
if (Lib::is_post() && isset($_POST['save'])) {
unset($_POST['save']);
unset($_POST['level']);
$page = self::handle_edit_form($id, 'levels', Lib::validate($_POST, [
'1_exp' => ['int', 'min:0'],
'1_hp' => ['int', 'min:0'],
'1_mp' => ['int', 'min:0'],
'1_tp' => ['int', 'min:0'],
'1_strength' => ['int', 'min:0'],
'1_dexterity' => ['int', 'min:0'],
'1_spells' => ['int', 'min:0'],
'2_exp' => ['int', 'min:0'],
'2_hp' => ['int', 'min:0'],
'2_mp' => ['int', 'min:0'],
'2_tp' => ['int', 'min:0'],
'2_strength' => ['int', 'min:0'],
'2_dexterity' => ['int', 'min:0'],
'2_spells' => ['int', 'min:0'],
'3_exp' => ['int', 'min:0'],
'3_hp' => ['int', 'min:0'],
'3_mp' => ['int', 'min:0'],
'3_tp' => ['int', 'min:0'],
'3_strength' => ['int', 'min:0'],
'3_dexterity' => ['int', 'min:0'],
'3_spells' => ['int', 'min:0'],
]), 'Level <b>'.$id.'</b> updated.');
} else {
$page = Lib::render('admin/edit_level', ['level' => $level]);
}
Lib::page_title('Admin: Editing Level '.$id);
return $page;
}
public static function users()
{
$users = Lib::db()->query('SELECT * FROM users ORDER BY id;');
$page = '<h2>Edit Users</h2>Click a username or ID to edit the account.<br><br><div class="table-wrapper">';
$page .= self::build_bulk_table($users, 'username', '/admin/users');
Lib::page_title('Admin: Users');
return $page.'</div>';
}
/**
* Handle editing a user.
*/
public static function edit_user(int $id): string
{
$user = Lib::db()->query('SELECT * FROM users WHERE id = ? LIMIT 1;', [$id])->fetchArray(SQLITE3_ASSOC);
if (Lib::is_post()) {
$form = Lib::validate($_POST, [
'username' => ['length:3-18', 'alpha-spaces', 'unique:users,username'],
'verify' => [],
'authlevel' => ['int'],
'email' => ['email', 'unique:users,email'],
'charclass' => ['in:1,2,3'],
'latitude' => ['int', 'min:0', 'max:'.Lib::env('game_size')],
'longitude' => ['int', 'min:0', 'max:'.Lib::env('game_size')],
'currentaction' => [],
'currentfight' => ['int'],
'currentmonster' => ['int'],
'currentmonsterhp' => ['int'],
'currentmonstersleep' => ['int'],
'currentmonsterimmune' => ['int'],
'currentuberdamage' => ['int'],
'currentuberdefense' => ['int'],
'currenthp' => ['int', 'min:0'],
'currentmp' => ['int', 'min:0'],
'currenttp' => ['int', 'min:0'],
'maxhp' => ['int', 'min:1'],
'maxmp' => ['int', 'min:1'],
'maxtp' => ['int', 'min:1'],
'level' => ['int', 'min:1'],
'gold' => ['int', 'min:0'],
'experience' => ['int', 'min:0'],
'goldbonus' => ['int'],
'expbonus' => ['int'],
'strength' => ['int'],
'dexterity' => ['int'],
'attackpower' => ['int'],
'defensepower' => ['int'],
'weaponid' => ['int'],
'armorid' => ['int'],
'shieldid' => ['int'],
'slot1id' => ['int'],
'slot2id' => ['int'],
'slot3id' => ['int'],
'weaponname' => ['default:None'],
'armorname' => ['default:None'],
'shieldname' => ['default:None'],
'slot1name' => ['default:None'],
'slot2name' => ['default:None'],
'slot3name' => ['default:None'],
'dropcode' => ['int', 'min:0', 'default:0'],
'spells' => ['optional'],
'towns' => ['optional'],
]);
if ($form['valid']) {
self::save_data_row('users', $form['data'], $id);
$page = 'User <b>'.$user['username'].'</b> updated.';
} else {
$error_list = Lib::ul_from_validate_errors($form['errors']);
$page = <<<HTML
<b>Errors:</b><br>
<div style="color: red;">{$error_list}</div><br>
Please go back and try again.
HTML;
}
} else {
$page = Lib::render('admin/edit_user', ['user' => $user]);
}
Lib::page_title('Admin: Editing '.$user['username']);
return $page;
}
/**
* Handling adding news posts.
*/
public static function add_news()
{
if (Lib::is_post()) {
$c = trim($_POST['content'] ?? '');
$errors = [];
if (empty($c)) {
$errors[] = 'Content is required.';
}
if (count($errors) === 0) {
Lib::db()->query('INSERT INTO news (author, content) VALUES (?, ?);', [Lib::user()->username, $c]);
$page = 'News post added.';
} else {
$error_list = implode('<br>', $errors);
$page = "<b>Errors:</b><br><div style=\"color:red;\">$error_list</div><br>Please go back and try again.";
}
} else {
$page = <<<HTML
<h2>Add a News Post</h2>
<form action="/admin/news" method="post" hx-post="/admin/news" hx-target="#main">
Type your post below and then click Submit to add it.<br>
<textarea name="content" rows="5" cols="50"></textarea><br>
<button type="submit">Submit</button>
<button type="reset">Clear</button>
</form>
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 = [
'<table><colgroup>',
str_repeat('<col>', count($columns)),
'</colgroup><thead><tr>',
];
foreach ($columns as $column) {
$html_parts[] = '<th>'.
Lib::make_safe($column === 'id' ? 'ID' : ucfirst($column)).
'</th>';
}
$html_parts[] = '</tr></thead><tbody>';
$is_edit_column = array_flip(['id', $edit_column]);
foreach ($data as $row) {
$html_parts[] = '<tr>';
foreach ($columns as $column) {
$name = Lib::make_safe($row[$column]);
$html_parts[] = isset($is_edit_column[$column])
? "<td><a href=\"{$edit_link}/{$row['id']}\" hx-get=\"{$edit_link}/{$row['id']}\" hx-target=\"#main\">{$name}</a></td>"
: "<td>{$name}</td>";
}
$html_parts[] = '</tr>';
}
$html_parts[] = '</tbody></table>';
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 ?: '<b>'.$form['data']['name'].'</b> updated.';
} else {
$error_list = Lib::ul_from_validate_errors($form['errors']);
$page = <<<HTML
<b>Errors:</b><br>
<div style="color: red;">{$error_list}</div><br>
Please go back and try again.
HTML;
}
return $page;
}
}