Compare commits

...

4 Commits

19 changed files with 354 additions and 56 deletions

View File

@ -19,7 +19,7 @@ The Update has not been merged into `master` yet, but it will be. In the meantim
- We're no longer using MySQL as the database! This was done for ease of install and operation; SQLite is plenty performant for Dragon Knight and makes it trivial to spin up new instances. The database is contained in `server/database/` as the file `dragon.db`. WAL mode is enabled, so you may see a couple extra files but this is expected.
- `lib.php` renamed to `server/library.php`
- The installer has been totally rewritten using the new database wrapper and a handful of new library functions.
- Classes have been totally reworked! Prior, they were hard-coded into the game's overall settings. This made them highly inflexible and allowed only three classes which all needed their own level rows to define. This sucked! Now, classes are their own rows in the `classes` table, with starting stats and stat growth per-level. They also now have a special syntax in the `spells` field to detail at what level what spells the player gets.
- Classes have been totally reworked! Prior, they were hard-coded into the game's overall settings. This made them highly inflexible and allowed only three classes which all needed their own level rows to define. This sucked! Now, classes are their own rows in the `classes` table, with starting stats and stat growth per-level. A class spells table now exists to allow admins to easily define what spells are awarded at what level per class.
- The help pages have been moved to the new structure and have been renamed to "Guide".
- Items now use a new special syntax similar to class spells to have multiple attributes; no more limits!

View File

@ -9,12 +9,21 @@ class App
public static Database $db;
private static string $dbPath;
public static Request $req;
public static Auth $auth;
public static array $s = []; // game settings
public function __construct(string $dbPath)
{
self::$req = new Request(); // the current request
self::$db = new Database($dbPath); // the database
self::$dbPath = $dbPath; // the database path
// load game settings
$s = self::$db->q('SELECT * FROM settings WHERE id = 1;');
self::$s = $s ? $s->fetch() : [];
// init authentication
self::$auth = new Auth();
}
public static function performDatabaseReset(): void
@ -24,4 +33,9 @@ class App
self::$db = new Database(self::$dbPath);
}
}
public static function auth(): bool
{
return self::$auth->good();
}
}

80
server/app/Auth.php Normal file
View File

@ -0,0 +1,80 @@
<?php
/*
Security, and especially authentication, is not a simple matter.
There's a lot to learn here.
*/
class Auth
{
// name of the remember me cookie
private const COOKIE_NAME = 'dragon-of-memory';
// id of the player
public static int $id = 0;
public function __construct()
{
$this->good();
}
public function login(string $identifier, string $password, bool $remember = false): bool
{
// delete the old session
if (isset($_SESSION['player_id'])) $this->logout();
// get the player by their username
$id = Player::validateCredentials($identifier, $password);
if ($id === false) return false;
// set the session
$_SESSION['player_id'] = $id;
self::$id = $id;
// set the remember me cookie
if ($remember) $this->remember($id);
return true;
}
private function remember(int $id): array|false
{
$data = ['player_id' => $id, 'token' => token()];
Session::createOrUpdate($data);
setcookie(self::COOKIE_NAME, implode('::', $data), strtotime('+30 days'), '/', '', true, true);
return $data;
}
private function logout(): void
{
if (isset($_SESSION['player_id'])) unset($_SESSION['player_id']);
if (isset($_COOKIE[self::COOKIE_NAME])) setcookie(self::COOKIE_NAME, '', time() - 86400, '/', '', true, true);
}
public function good(): bool
{
// if our player_id session still exists, carry on
if (isset($_SESSION['player_id'])) {
self::$id = $_SESSION['player_id'];
return true;
}
// if a remember me cookie exists, try to validate it
if (isset($_COOKIE[self::COOKIE_NAME])) {
$cookie = explode('::', $_COOKIE[self::COOKIE_NAME]); // player_id::token
// try to validate the token
if (!Session::validate($cookie[0], $cookie[1])) return false; // the token is invalid
// token is valid, refresh cookie and assign session
$this->remember($cookie[0]);
$_SESSION['player_id'] = $cookie[0];
self::$id = $cookie[0];
return true;
}
return false;
}
}

View File

@ -6,7 +6,23 @@ error_reporting(E_ALL | E_STRICT);
define('START', microtime(true)); // start the timer for this execution
session_start(); // initialize the session engine
// adjust session settings
ini_set('session.gc_maxlifetime', 604800); // 1 week in seconds
ini_set('session.cookie_lifetime', 604800); // 1 week in seconds
// ensure secure session handling
ini_set('session.use_strict_mode', 1);
ini_set('session.cookie_httponly', 1);
ini_set('session.cookie_secure', 1); // only if using HTTPS
// start the session
session_start();
// regenerate session ID to prevent session fixation
if (!isset($_SESSION['initiated'])) {
session_regenerate_id(true);
$_SESSION['initiated'] = true;
}
// @todo move these to a settings config somewhere
const VERSION = '1.1.11';
@ -23,17 +39,21 @@ const MAP = [
// 'Class' => 'path/to/class.php',
// server-level classes
'App' => SERVER.'/app/app.php',
'Database' => SERVER.'/app/database.php',
'Request' => SERVER.'/app/request.php',
'App' => SERVER.'/app/App.php',
'Database' => SERVER.'/app/Database.php',
'Request' => SERVER.'/app/Request.php',
'Auth' => SERVER.'/app/Auth.php',
// modules
'HomeModule' => SERVER.'/modules/HomeModule.php',
'InstallModule' => SERVER.'/modules/InstallModule.php',
'GateModule' => SERVER.'/modules/GateModule.php',
// models
'Classes' => SERVER.'/models/Classes.php',
'Player' => SERVER.'/models/Player.php',
'Spell' => SERVER.'/models/Spell.php',
'Session' => SERVER.'/models/Session.php',
];
// autoloader

View File

@ -2,7 +2,7 @@
This folder serves as the home for the game's database; `dragon.db` by default. This is a WAL- and foreign key-enabled SQLite database wrapped in a very thin class based on the PDO wrapper in PHP. In production, the `dragon.db` file will be created if it doesn't exist, and the installer should be used to populate the database. This file does not exist in the repo.
### Packs
New to Dragon Knight is the ability to upload "data packs" to the game! Using this feature, it is possible to upload and store `.zip` files that contain `.csv` files (spreadsheets) of data for the game. These spreadsheets must have a 1:1 structure to what's expected in Dragon Knight. This allows an admin to populate the game data quickly and easily with data they either make or get from someone else.
New to Dragon Knight is the ability to upload "data packs" to the game! Using this feature, it is possible to upload `.zip` files that contain `.csv` files (spreadsheets) of data for the game. These spreadsheets must have a 1:1 structure to what's expected in Dragon Knight. This allows an admin to populate the game data quickly and easily with data they either make or get from someone else.
The `Default` data pack is the default data used when doing a **Complete** install of Dragon Knight. You can edit this before running the installer to change the default data. You can also use it as a template for your own data packs!

View File

@ -0,0 +1,6 @@
Level,"Class ID","Spell ID"
1,1,6
1,1,18
1,3,1
1,3,15
1,3,18
1 Level Class ID Spell ID
2 1 1 6
3 1 1 18
4 1 3 1
5 1 3 15
6 1 3 18

View File

@ -1,4 +1,4 @@
Name,"Start HP","Start MP","Start STR","Start ATK","Start DEF","Start DEX","Growth HP","Growth MP","Growth STR","Growth ATK","Growth DEF","Growth DEX",Spells
Mage,10,10,5,5,5,5,3,5,1,3,1,3,"1:6,18"
Warrior,20,0,10,5,10,5,6,2,3,1,3,1,
Paladin,15,5,5,5,10,10,4,4,2,2,2,2,"1:1,15,18"
Name,"Start HP","Start MP","Start STR","Start ATK","Start DEF","Start DEX","Growth HP","Growth MP","Growth STR","Growth ATK","Growth DEF","Growth DEX"
Mage,10,10,5,5,5,5,3,5,1,3,1,3
Warrior,20,0,10,5,10,5,6,2,3,1,3,1
Paladin,15,5,5,5,10,10,4,4,2,2,2,2

1 Name Start HP Start MP Start STR Start ATK Start DEF Start DEX Growth HP Growth MP Growth STR Growth ATK Growth DEF Growth DEX Spells
2 Mage 10 10 5 5 5 5 3 5 1 3 1 3 1:6,18
3 Warrior 20 0 10 5 10 5 6 2 3 1 3 1
4 Paladin 15 5 5 5 10 10 4 4 2 2 2 2 1:1,15,18

View File

@ -1,32 +1,42 @@
<?php // library.php :: Common functions used throughout the program.
// Diff two times in seconds.
function stopwatch(float $start, int $roundTo = 3): float
{
return round(microtime(true) - $start, $roundTo);
}
// Redirect to another page.
function redirect(string $url): void
{
header("Location: $url");
exit;
}
// Redirect to the install page if not installed.
// Otherwise, redirect to the main page if requesting the install page.
function installRedirect(string $route)
{
if (!INSTALLED && $route != 'install') redirect('/install');
if (INSTALLED && $route == 'install') redirect('/');
}
// Get the path to a template.
function template(string $name): string
{
return SERVER."/templates/$name.php";
}
/**
* Renders a template. Pass data to it - uses an output buffer to have PHP process the template instead of using
* a template engine. If you're including partials in the page, call `render('partial', $data)`, as $data will still
* be available.
*/
// Checks if all required fields are set.
function required(array $keys): bool
{
foreach ($keys as $key) if (!isset($_POST[$key]) || empty($_POST[$key])) return false;
return true;
}
// Renders a template. Pass data to it - uses an output buffer to have PHP process the template instead of using
// a template engine. If you're including partials in the page, call `render('partial', $data)`, as $data will still
// be available.
function render(string $baseView, array $data = []): string
{
ob_start();
@ -35,20 +45,7 @@ function render(string $baseView, array $data = []): string
return ob_get_clean();
}
/**
* Checks if all required fields are set.
*/
function required(array $keys): bool
{
foreach ($keys as $key) {
if (!isset($_POST[$key]) || empty($_POST[$key])) return false;
}
return true;
}
/**
* Dump and die. Useful for debugging.
*/
// Dump and die.
function dd(mixed $var, bool $r = false)
{
echo '<pre>';
@ -57,11 +54,32 @@ function dd(mixed $var, bool $r = false)
exit;
}
function getmicrotime() { // Used for timing script operations.
// Calculate the EXP to level up, given a level.
function expToLevel(int $level): int
{
// constants for the quadratic formula
$a = 5; // adjust this value to change the curve's steepness
$b = 20; // adjust this value to change the initial XP increase
$baseOffset = 0; // starting point, no initial offset
list($usec, $sec) = explode(" ",microtime());
return ((float)$usec + (float)$sec);
if ($level == 1) return 0; // level 1 does not require any XP
// calculate the dynamic offset based on the level
$additionalOffset = floor(($level - 1) / 20) * 100;
// total offset
$c = $baseOffset + $additionalOffset;
// calculate the experience required for the given level
$experience = $a * pow($level - 1, 2) + $b * ($level - 1) + $c;
return $experience;
}
// Generate a 32 byte cryptographically secure random hex string.
function token(int $length = 32): string
{
return bin2hex(random_bytes($length));
}
function prettydate($uglydate) { // Change the MySQL date format (YYYY-MM-DD) into something friendlier.

View File

@ -7,4 +7,23 @@ class Classes
$res = App::$db->q("SELECT * FROM classes");
return $res->fetchAll() ?: false;
}
public static function get(int $id): array|false
{
$res = App::$db->do("SELECT * FROM classes WHERE id = ?", [$id]);
return $res->fetch() ?: false;
}
public static function spells(int $id): array|false
{
$res = App::$db->do("SELECT level, spell_id FROM class_spells WHERE class_id = ?", [$id]);
return $res->fetchAll() ?: false;
}
public static function spellsAtLevel(int $id, int $level): array|false
{
// get all spells for the class under or equal to the level
$ids = App::$db->do("SELECT spell_id FROM class_spells WHERE class_id = ? AND level <= ?", [$id, $level]);
return $ids->fetchAll(PDO::FETCH_COLUMN) ?: false;
}
}

View File

@ -4,10 +4,52 @@ class Player
{
public static function create(array $data): int
{
// get the player's class
$class = Classes::get($data['class_id'] ?? 1);
if ($class == false) die('Player::create: Invalid class selected. ' . print_r($data, true));
// get player level
$l = $data['level'] ?? 1;
// calculate player stats
$data['hp'] = $data['max_hp'] = $data['max_hp'] ?? ($class['start_hp'] + ($class['growth_hp'] * ($l - 1)));
$data['mp'] = $data['max_mp'] = $data['max_mp'] ?? ($class['start_mp'] + ($class['growth_mp'] * ($l - 1)));
$data['tp'] = $data['max_tp'] = $data['max_tp'] ?? (5 + (App::$s['tp_growth'] * ($l - 1)));
$data['str'] = $data['str'] ?? ($class['start_str'] + ($class['growth_str'] * ($l - 1)));
$data['atk'] = $data['atk'] ?? ($class['start_atk'] + ($class['growth_atk'] * ($l - 1)));
$data['def'] = $data['def'] ?? ($class['start_def'] + ($class['growth_def'] * ($l - 1)));
$data['dex'] = $data['dex'] ?? ($class['start_dex'] + ($class['growth_dex'] * ($l - 1)));
$data['stat_points'] = $data['stat_points'] ?? (App::$s['stat_point_gain'] * ($l - 1));
$data['exp2l'] = $data['exp2l'] ?? expToLevel($l + 1);
// award spells
if (!isset($data['spells']) || empty($data['spells'])) {
$spells = Classes::spellsAtLevel($class['id'], $l);
$data['spells'] = implode(',', $spells);
}
// compress data and generate placeholers
$keys = implode(', ', array_keys($data));
$placeholders = implode(', ', array_fill(0, count($data), '?'));
// insert into db
App::$db->do("INSERT INTO 'players' ($keys) VALUES ($placeholders);", array_values($data));
return App::$db->lastInsertID();
}
public static function validateCredentials(string $identifier, string $password, bool $fetch = false): int|false
{
// get the player from their username or email
$player = App::$db->do("SELECT " . ($fetch ? '*' : 'id, password') . " FROM players WHERE username = :i OR email = :i LIMIT 1;", ['i' => $identifier]);
if ($player == false) return false;
$player = $player->fetch();
// check password, return the player data if good
if (password_verify($password, $player['password'])) {
unset($player['password']);
return $fetch ? $player : $player['id'];
}
return false;
}
}

32
server/models/Session.php Normal file
View File

@ -0,0 +1,32 @@
<?php
class Session
{
public static function createOrUpdate(array $data): void
{
App::$db->do("INSERT OR REPLACE INTO sessions (player_id, token, expires) VALUES (?, ?, DATETIME(CURRENT_TIMESTAMP, '+30 days'));", $data);
}
public static function get(int $id): array|false
{
$session = App::$db->do("SELECT * FROM sessions WHERE player_id = ? LIMIT 1;", [$id]);
return $session->fetch() ?: false;
}
public static function delete(int $id): void
{
App::$db->do("DELETE FROM sessions WHERE player_id = ?;", [$id]);
}
public static function validate(int $id, string $token): bool
{
$session = App::$db->do("SELECT * FROM sessions WHERE player_id = ? AND token = ? LIMIT 1;", [$id, $token]);
if ($session === false) return false;
$session = $session->fetch();
// if the current time is after the expires column, the token is invalid
if (strtotime($session['expires']) < time()) return false;
return true;
}
}

27
server/models/Spell.php Normal file
View File

@ -0,0 +1,27 @@
<?php
class Spell
{
public static function all(): array|false
{
$spells = App::$db->do("SELECT * FROM spells");
return $spells->fetchAll() ?: false;
}
public static function get(int $id): array|false
{
$spell = App::$db->do("SELECT * FROM spells WHERE id = ?", [$id]);
return $spell->fetch() ?: false;
}
public static function getFromList(string|array $list): array
{
if (is_string($list)) $list = explode(',', $list);
$spells = [];
foreach ($list as $id) {
$spell = self::get($id);
if ($spell !== false) $spells[] = $spell;
}
return $spells;
}
}

View File

@ -0,0 +1,6 @@
<?php
class GateModule
{
}

View File

@ -4,7 +4,12 @@ class HomeModule
{
public static function home()
{
echo 'Welcome to the home module!';
if (App::auth()) {
echo 'You are already logged in!<br>';
} else {
echo 'You are not logged in!<br>';
}
echo 'Your request is: ' . App::$req->uri(0);
}
}

View File

@ -34,7 +34,7 @@ class InstallModule
// @Settings
App::$db->q("CREATE TABLE IF NOT EXISTS 'settings' (
'id' INTEGER PRIMARY KEY,
'id' INTEGER PRIMARY KEY AUTOINCREMENT,
'game_name' TEXT DEFAULT 'Dragon Knight',
'game_version' TEXT DEFAULT '1.0',
'game_dev' TEXT DEFAULT 'Sharkk',
@ -47,7 +47,9 @@ class InstallModule
'verify_email' INT DEFAULT 1,
'show_news' INT DEFAULT 1,
'show_online' INT DEFAULT 1,
'show_babble' INT DEFAULT 1
'show_babble' INT DEFAULT 1,
'tp_growth' INT DEFAULT 1,
'stat_point_gain' INT DEFAULT 5
);");
// insert default settings
@ -55,7 +57,7 @@ class InstallModule
// @Classes
App::$db->q("CREATE TABLE IF NOT EXISTS 'classes' (
'id' INTEGER PRIMARY KEY,
'id' INTEGER PRIMARY KEY AUTOINCREMENT,
'name' TEXT DEFAULT '',
'start_hp' INT DEFAULT 0,
'start_mp' INT DEFAULT 0,
@ -68,8 +70,7 @@ class InstallModule
'growth_str' INT DEFAULT 0,
'growth_atk' INT DEFAULT 0,
'growth_dex' INT DEFAULT 0,
'growth_def' INT DEFAULT 0,
'spells' TEXT DEFAULT ''
'growth_def' INT DEFAULT 0
);");
if ($complete) {
@ -82,7 +83,7 @@ class InstallModule
// @Babble
App::$db->q("CREATE TABLE IF NOT EXISTS 'babble' (
'id' INTEGER PRIMARY KEY,
'id' INTEGER PRIMARY KEY AUTOINCREMENT,
'author' INTEGER NOT NULL,
'babble' TEXT NOT NULL,
'posted' DATETIME DEFAULT CURRENT_TIMESTAMP
@ -90,7 +91,7 @@ class InstallModule
// @Drops
App::$db->q("CREATE TABLE IF NOT EXISTS 'drops' (
'id' INTEGER PRIMARY KEY,
'id' INTEGER PRIMARY KEY AUTOINCREMENT,
'name' TEXT NOT NULL,
'level' INTEGER DEFAULT 1,
'type' INTEGER DEFAULT 1,
@ -102,7 +103,7 @@ class InstallModule
// @Forum
App::$db->q("CREATE TABLE IF NOT EXISTS 'forum' (
'id' INTEGER PRIMARY KEY,
'id' INTEGER PRIMARY KEY AUTOINCREMENT,
'posted' DATETIME DEFAULT CURRENT_TIMESTAMP,
'new_post' DATETIME DEFAULT CURRENT_TIMESTAMP,
'author' INTEGER NOT NULL,
@ -118,7 +119,7 @@ class InstallModule
// @Items
App::$db->q("CREATE TABLE IF NOT EXISTS 'items' (
'id' INTEGER PRIMARY KEY,
'id' INTEGER PRIMARY KEY AUTOINCREMENT,
'type' INTEGER DEFAULT 1,
'name' TEXT NOT NULL,
'cost' INTEGER DEFAULT 0,
@ -131,7 +132,7 @@ class InstallModule
// @Monsters
App::$db->q("CREATE TABLE IF NOT EXISTS 'monsters' (
'id' INTEGER PRIMARY KEY,
'id' INTEGER PRIMARY KEY AUTOINCREMENT,
'name' TEXT NOT NULL,
'level' INTEGER DEFAULT 1,
'hp' INTEGER DEFAULT 1,
@ -148,7 +149,7 @@ class InstallModule
// @News
App::$db->q("CREATE TABLE IF NOT EXISTS 'news' (
'id' INTEGER PRIMARY KEY,
'id' INTEGER PRIMARY KEY AUTOINCREMENT,
'author' INTEGER DEFAULT 1,
'title' TEXT DEFAULT '',
'content' TEXT DEFAULT '',
@ -157,7 +158,7 @@ class InstallModule
// @Spells
App::$db->q("CREATE TABLE IF NOT EXISTS 'spells' (
'id' INTEGER PRIMARY KEY,
'id' INTEGER PRIMARY KEY AUTOINCREMENT,
'name' TEXT NOT NULL,
'type' INTEGER DEFAULT 1,
'mp' INTEGER DEFAULT 0,
@ -168,6 +169,16 @@ class InstallModule
// add default spells if complete install
if ($complete) App::$db->insertFromCSV('spells', "$defaults/spells.csv");
// @LearnedSpells
App::$db->q("CREATE TABLE IF NOT EXISTS 'class_spells' (
'level' INTEGER NOT NULL,
'class_id' INTEGER NOT NULL,
'spell_id' INTEGER NOT NULL
);");
// add default class spells if complete install
if ($complete) App::$db->insertFromCSV('class_spells', "$defaults/class_spells.csv");
// @Towns
App::$db->q("CREATE TABLE IF NOT EXISTS 'towns' (
'id' INTEGER PRIMARY KEY,
@ -198,14 +209,15 @@ class InstallModule
'class_id' INTEGER DEFAULT 1,
'level' INTEGER DEFAULT 1,
'exp' INTEGER DEFAULT 0,
'exp2l' INTEGER DEFAULT 0,
'gold' INTEGER DEFAULT 0,
'stat_points' INTEGER DEFAULT 0,
'hp' INTEGER DEFAULT 0,
'max_hp' INTEGER DEFAULT 0,
'mp' INTEGER DEFAULT 0,
'max_mp' INTEGER DEFAULT 0,
'tp' INTEGER DEFAULT 0,
'max_tp' INTEGER DEFAULT 0,
'tp' INTEGER DEFAULT 5,
'max_tp' INTEGER DEFAULT 5,
'str' INTEGER DEFAULT 0,
'atk' INTEGER DEFAULT 0,
'dex' INTEGER DEFAULT 0,
@ -232,7 +244,14 @@ class InstallModule
'p_maxmp' INTEGER DEFAULT 0,
'm_hp' INTEGER DEFAULT 0,
'm_maxhp' INTEGER DEFAULT 0,
'condi' TEXT DEFAULT ''
'effects' TEXT DEFAULT ''
);");
// @Sessions
App::$db->q("CREATE TABLE IF NOT EXISTS 'sessions' (
'player_id' INTEGER NOT NULL UNIQUE,
'token' TEXT NOT NULL,
'expires' DATETIME NOT NULL
);");
echo render('install/layout', ['title' => 'Database Setup', 'step' => 'second', 'complete' => $complete, 'start' => $istart]);
@ -255,8 +274,8 @@ class InstallModule
}
}
// Make sure the class selection is valid
$class = isset($_POST['class']) && in_array($_POST['class'], [1, 2, 3]) ? $_POST['class'] : 1;
// Make sure the class selection is present
$class = $_POST['class'] ?? 1;
// If we have any errors, bail to the form and let the user know
if (!empty($errors)) {
@ -264,9 +283,6 @@ class InstallModule
exit;
}
// Create the .installed file in the server folder
file_put_contents(SERVER.'/.installed', 'Installed on '.date('Y-m-d H:i:s'));
// Create the admin account
Player::create([
'username' => trim($_POST['username']),
@ -274,11 +290,19 @@ class InstallModule
'email' => trim($_POST['email']),
'class_id' => $class,
'verified' => 1,
'role' => 5
'role' => 5,
'level' => $_POST['level'] ?? 1
]);
// Create the .installed file in the server folder
file_put_contents(SERVER.'/.installed', 'Installed on '.date('Y-m-d H:i:s'));
// login the admin
App::$auth->login($_POST['username'], $_POST['password']);
// Render the finished page!
echo render('install/layout', ['title' => 'Finished!', 'step' => 'done', 'name' => $_POST['username'], 'complete' => $_POST['complete'] ?? false]);
}
private static function fourOhFour()

View File

@ -1,12 +1,12 @@
<p class="mb-1">
Congratulations, <?= $name ?>! Your installation is complete. Dragon Knight is ready to go.
All that's left is to log in and start playing. <?php if (!$complete): ?>Once you've logged in,
All that's left is to start playing. <?php if (!$complete): ?>Once you've logged in,
you can create some classes and assign your character one. By default you are a useless Adventurer.
😜<?php endif; ?>
</p>
<p class="mb-1">
<a href="/gate/login">Click here to log in.</a>
<a href="/gate/login">Click here to begin your adventure.</a>
</p>
<p>

View File

@ -27,5 +27,10 @@
</div>
<?php endif; ?>
<div class="form-group">
<label for="level">Level</label>
<input type="number" name="level" id="level" min="1" placeholder="1" value="1" required>
</div>
<button type="submit" name="submit">Submit</button>
</form>