initial commit

This commit is contained in:
Sky Johnson 2024-09-27 18:45:33 -05:00
commit b063740547
26 changed files with 1508 additions and 0 deletions

9
.env Normal file
View File

@ -0,0 +1,9 @@
debug = true
open = true
world_size = 250
exp_modifier = 1
silver_modifier = 1
allow_pvp = true
allow_registration = true
start_silver = 100
sp_per_level = 5

15
color.php Normal file
View File

@ -0,0 +1,15 @@
<?php
/**
* A collection of functions to colorize the output of the terminal.
*/
function c(string $c, string $s): string { return $c . $s . "\033[0m"; }
function black(string $s): string { return c("\033[30m", $s); }
function red(string $s): string { return c("\033[31m", $s); }
function green(string $s): string { return c("\033[32m", $s); }
function yellow(string $s): string { return c("\033[33m", $s); }
function blue(string $s): string { return c("\033[34m", $s); }
function magenta(string $s): string { return c("\033[35m", $s); }
function cyan(string $s): string { return c("\033[36m", $s); }
function white(string $s): string { return c("\033[37m", $s); }

BIN
database/auth.db Normal file

Binary file not shown.

BIN
database/blueprints.db Normal file

Binary file not shown.

BIN
database/fights.db Normal file

Binary file not shown.

BIN
database/live.db Normal file

Binary file not shown.

540
database/scripts/create.php Normal file
View File

@ -0,0 +1,540 @@
<?php
/*
This script is used to create the database and the tables for said database.
php create.php <database>
*/
require_once __DIR__ . '/../../color.php';
const AUTH = 'auth.db';
const LIVE = 'live.db';
const FIGHTS = 'fights.db';
const BPS = 'blueprints.db';
/**
* Echo a string with a newline.
*/
function eln(string $string): void
{
echo $string . PHP_EOL;
}
// pick the database to create
if (!isset($argv[1])) {
eln(red('Missing database name.'));
eln(blue('Usage: ') . 'php create.php auth.db|live.db|fight.db|blueprints.db [-d]');
exit(1);
}
// make sure it's a valid database
if (!in_array($argv[1], [AUTH, LIVE, FIGHTS, BPS])) {
eln(red('Invalid database: ') . $argv[1]);
exit(1);
}
$database = $argv[1];
// whether the -d flag is set
$drop = isset($argv[2]) && $argv[2] === '-d';
/*
================================================================================
Databases
================================================================================
*/
/*
The Auth database is used to store user information - not player info, but user info.
Usernames, passwords, email, session tokens, etc.
*/
if ($database === AUTH) {
if ($drop) {
unlink(__DIR__ . '/../' . AUTH);
eln(red('Dropped database: ') . 'auth.db');
}
$db = new SQLite3(__DIR__ . '/../' . AUTH);
// Users table
$db->exec('DROP TABLE IF EXISTS users');
$db->exec('CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
password TEXT NOT NULL,
auth INT NOT NULL DEFAULT 0,
created DATETIME DEFAULT CURRENT_TIMESTAMP,
last_login DATETIME DEFAULT CURRENT_TIMESTAMP
)');
eln(yellow('Created table: ') . 'users');
// Sessions table
$db->exec('DROP TABLE IF EXISTS sessions');
$db->exec('CREATE TABLE sessions (
user_id INTEGER NOT NULL,
token TEXT NOT NULL UNIQUE,
expires INTEGER NOT NULL
)');
eln(yellow('Created table: ') . 'sessions');
// Verification tokens
$db->exec('DROP TABLE IF EXISTS tokens');
$db->exec('CREATE TABLE tokens (
user_id INTEGER NOT NULL,
token TEXT NOT NULL UNIQUE,
created INTEGER NOT NULL
)');
eln(yellow('Created table: ') . 'tokens');
eln(green('Created database: ') . 'auth.db');
exit(0);
}
/*
The Fights database is used to store information about fights.
A fight is a battle between two characters; players or NPCs.
*/
if ($database === FIGHTS) {
if ($drop) {
unlink(__DIR__ . '/../' . FIGHTS);
eln(red('Dropped database: ') . 'fights.db');
}
$db = new SQLite3(__DIR__ . '/../' . FIGHTS);
// PvE fights
$db->exec('DROP TABLE IF EXISTS pve');
$db->exec('CREATE TABLE pve (
id INTEGER PRIMARY KEY AUTOINCREMENT,
player_id INTEGER NOT NULL,
player_hp INTEGER NOT NULL,
player_max_hp INTEGER NOT NULL,
player_mp INTEGER NOT NULL,
player_max_mp INTEGER NOT NULL,
player_power INTEGER NOT NULL,
player_toughness INTEGER NOT NULL,
player_armor INTEGER NOT NULL,
player_precision INTEGER NOT NULL,
player_crit INTEGER NOT NULL,
player_ferocity INTEGER NOT NULL,
player_vitality INTEGER NOT NULL,
mob_id INTEGER NOT NULL,
mob_level INTEGER NOT NULL,
mob_rank INTEGER NOT NULL,
mob_hp INTEGER NOT NULL,
mob_max_hp INTEGER NOT NULL,
mob_mp INTEGER NOT NULL,
mob_max_mp INTEGER NOT NULL,
mob_power INTEGER NOT NULL,
mob_toughness INTEGER NOT NULL,
mob_armor INTEGER NOT NULL,
mob_precision INTEGER NOT NULL,
mob_crit INTEGER NOT NULL,
mob_ferocity INTEGER NOT NULL,
mob_vitality INTEGER NOT NULL,
first_turn INTEGER NOT NULL,
turn INTEGER NOT NULL default 1,
winner INTEGER NOT NULL default 0,
flee INTEGER NOT NULL default 1,
escaped INTEGER NOT NULL default 0,
created DATETIME DEFAULT CURRENT_TIMESTAMP,
updated DATETIME DEFAULT CURRENT_TIMESTAMP
)');
eln(yellow('Created table: ') . 'pve');
// PvP fights
$db->exec('DROP TABLE IF EXISTS pvp');
$db->exec('CREATE TABLE pvp (
id INTEGER PRIMARY KEY AUTOINCREMENT,
player1_id INTEGER NOT NULL,
player1_hp INTEGER NOT NULL,
player1_max_hp INTEGER NOT NULL,
player1_mp INTEGER NOT NULL,
player1_max_mp INTEGER NOT NULL,
player1_power INTEGER NOT NULL,
player1_toughness INTEGER NOT NULL,
player1_armor INTEGER NOT NULL,
player1_precision INTEGER NOT NULL,
player1_crit INTEGER NOT NULL,
player1_ferocity INTEGER NOT NULL,
player1_vitality INTEGER NOT NULL,
player2_id INTEGER NOT NULL,
player2_hp INTEGER NOT NULL,
player2_max_hp INTEGER NOT NULL,
player2_mp INTEGER NOT NULL,
player2_max_mp INTEGER NOT NULL,
player2_power INTEGER NOT NULL,
player2_toughness INTEGER NOT NULL,
player2_armor INTEGER NOT NULL,
player2_precision INTEGER NOT NULL,
player2_crit INTEGER NOT NULL,
player2_ferocity INTEGER NOT NULL,
player2_vitality INTEGER NOT NULL,
first_turn INTEGER NOT NULL,
turn INTEGER NOT NULL default 1,
winner INTEGER NOT NULL default 0,
created DATETIME DEFAULT CURRENT_TIMESTAMP,
updated DATETIME DEFAULT CURRENT_TIMESTAMP
)');
eln(yellow('Created table: ') . 'pvp');
// PvE fight logs
$db->exec('DROP TABLE IF EXISTS pve_logs');
$db->exec('CREATE TABLE pve_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
fight_id INTEGER NOT NULL,
info TEXT NOT NULL
)');
eln(yellow('Created table: ') . 'pve_logs');
// PvP fight logs
$db->exec('DROP TABLE IF EXISTS pvp_logs');
$db->exec('CREATE TABLE pvp_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
fight_id INTEGER NOT NULL,
info TEXT NOT NULL
)');
eln(yellow('Created table: ') . 'pvp_logs');
eln(green('Created database: ') . 'fights.db');
exit(0);
}
/*
The Blueprints database is used to store information about items, weapons, armor, etc.
*/
if ($database === BPS) {
if ($drop) {
unlink(__DIR__ . '/../' . BPS);
eln(red('Dropped database: ') . 'blueprints.db');
}
$db = new SQLite3(__DIR__ . '/../' . BPS);
// Items
$db->exec('DROP TABLE IF EXISTS items');
$db->exec('CREATE TABLE items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
type INTEGER NOT NULL DEFAULT 0,
subtype INTEGER NOT NULL DEFAULT 0,
slot INTEGER NOT NULL DEFAULT 0,
rarity INTEGER NOT NULL DEFAULT 0,
value INTEGER NOT NULL DEFAULT 0,
consumable INTEGER NOT NULL DEFAULT 0,
duration INTEGER NOT NULL DEFAULT 0,
durability INTEGER NOT NULL DEFAULT 0,
power INTEGER NOT NULL DEFAULT 0,
toughness INTEGER NOT NULL DEFAULT 0,
armor INTEGER NOT NULL DEFAULT 0,
precision INTEGER NOT NULL DEFAULT 0,
crit INTEGER NOT NULL DEFAULT 0,
ferocity INTEGER NOT NULL DEFAULT 0,
vitality INTEGER NOT NULL DEFAULT 0,
reqs TEXT NOT NULL DEFAULT "",
traits TEXT NOT NULL DEFAULT "",
lore TEXT NOT NULL DEFAULT "",
created DATETIME DEFAULT CURRENT_TIMESTAMP,
updated DATETIME DEFAULT CURRENT_TIMESTAMP
)');
eln(yellow('Created table: ') . 'items');
// Mobs
$db->exec('DROP TABLE IF EXISTS mobs');
$db->exec('CREATE TABLE mobs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
type INTEGER NOT NULL,
rank INTEGER NOT NULL,
level INTEGER NOT NULL,
hp INTEGER NOT NULL,
max_hp INTEGER NOT NULL,
mp INTEGER NOT NULL,
max_mp INTEGER NOT NULL,
power INTEGER NOT NULL,
toughness INTEGER NOT NULL,
armor INTEGER NOT NULL,
precision INTEGER NOT NULL,
crit INTEGER NOT NULL,
ferocity INTEGER NOT NULL,
vitality INTEGER NOT NULL,
xp INTEGER NOT NULL,
silver INTEGER NOT NULL,
loot TEXT NOT NULL,
lore TEXT NOT NULL,
created DATETIME DEFAULT CURRENT_TIMESTAMP,
updated DATETIME DEFAULT CURRENT_TIMESTAMP
)');
eln(yellow('Created table: ') . 'mobs');
eln(green('Created database: ') . 'blueprints.db');
exit(0);
}
/*
The Live database is used to store information about players, NPCs, guilds, etc.
*/
if ($database === LIVE) {
if ($drop) {
unlink(__DIR__ . '/../' . LIVE);
eln(red('Dropped database: ') . 'live.db');
}
$db = new SQLite3(__DIR__ . '/../' . LIVE);
// Players
$db->exec('DROP TABLE IF EXISTS players');
$db->exec('CREATE TABLE players (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
name TEXT NOT NULL UNIQUE,
title_id INTEGER NOT NULL DEFAULT,
level INTEGER NOT NULL DEFAULT 1,
xp INTEGER NOT NULL DEFAULT 0,
xp_to_level INTEGER NOT NULL DEFAULT 100,
current_hp INTEGER NOT NULL DEFAULT 20,
max_hp INTEGER NOT NULL DEFAULT 20,
current_mp INTEGER NOT NULL DEFAULT 10,
max_mp INTEGER NOT NULL DEFAULT 10,
current_tp INTEGER NOT NULL DEFAULT 0,
max_tp INTEGER NOT NULL DEFAULT 0,
power INTEGER NOT NULL DEFAULT 0,
toughness INTEGER NOT NULL DEFAULT 0,
armor INTEGER NOT NULL DEFAULT 0,
precision INTEGER NOT NULL DEFAULT 0,
crit INTEGER NOT NULL DEFAULT 0,
ferocity INTEGER NOT NULL DEFAULT 0,
vitality INTEGER NOT NULL DEFAULT 0,
inv_slots INTEGER NOT NULL DEFAULT 10
)');
eln(yellow('Created table: ') . 'players');
// Player gear
$db->exec('DROP TABLE IF EXISTS player_gear');
$db->exec('CREATE TABLE player_gear (
player_id INTEGER NOT NULL,
head INTEGER NOT NULL DEFAULT 0,
chest INTEGER NOT NULL DEFAULT 0,
boots INTEGER NOT NULL DEFAULT 0,
hands INTEGER NOT NULL DEFAULT 0,
main_hand INTEGER NOT NULL DEFAULT 0,
off_hand INTEGER NOT NULL DEFAULT 0,
rune INTEGER NOT NULL DEFAULT 0,
ring INTEGER NOT NULL DEFAULT 0,
amulet INTEGER NOT NULL DEFAULT 0,
power INTEGER NOT NULL DEFAULT 0,
toughness INTEGER NOT NULL DEFAULT 0,
armor INTEGER NOT NULL DEFAULT 0,
precision INTEGER NOT NULL DEFAULT 0,
crit INTEGER NOT NULL DEFAULT 0,
ferocity INTEGER NOT NULL DEFAULT 0,
vitality INTEGER NOT NULL DEFAULT 0,
max_hp INTEGER NOT NULL DEFAULT 0,
max_mp INTEGER NOT NULL DEFAULT 0,
traits TEXT NOT NULL DEFAULT ""
)');
eln(yellow('Created table: ') . 'player_gear');
// Player inventory
$db->exec('DROP TABLE IF EXISTS player_inventory');
$db->exec('CREATE TABLE inventory (
player_id INTEGER NOT NULL,
item_id INTEGER NOT NULL
)');
eln(yellow('Created table: ') . 'inventory');
// Player wallet
$db->exec('DROP TABLE IF EXISTS player_wallet');
$db->exec('CREATE TABLE wallet (
player_id INTEGER NOT NULL,
silver INTEGER NOT NULL DEFAULT 10,
stargem INTEGER NOT NULL DEFAULT 0
)');
eln(yellow('Created table: ') . 'wallet');
// Player bank
$db->exec('DROP TABLE IF EXISTS player_bank');
$db->exec('CREATE TABLE bank (
player_id INTEGER NOT NULL,
slots INTEGER NOT NULL DEFAULT 5,
silver INTEGER NOT NULL DEFAULT 0,
tier INTEGER NOT NULL DEFAULT 1,
interest INTEGER NOT NULL DEFAULT 0
)');
eln(yellow('Created table: ') . 'bank');
// Banked items
$db->exec('DROP TABLE IF EXISTS player_banked_items');
$db->exec('CREATE TABLE banked_items (
player_id INTEGER NOT NULL,
item_id INTEGER NOT NULL
)');
eln(yellow('Created table: ') . 'banked_items');
// Towns
$db->exec('DROP TABLE IF EXISTS towns');
$db->exec('CREATE TABLE towns (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
x INTEGER NOT NULL,
y INTEGER NOT NULL,
type INTEGER NOT NULL,
lore TEXT NOT NULL,
created DATETIME DEFAULT CURRENT_TIMESTAMP,
updated DATETIME DEFAULT CURRENT_TIMESTAMP
)');
eln(yellow('Created table: ') . 'towns');
// Shops
$db->exec('DROP TABLE IF EXISTS shops');
$db->exec('CREATE TABLE shops (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
type INTEGER NOT NULL,
lore TEXT NOT NULL,
x INTEGER NOT NULL,
y INTEGER NOT NULL,
items TEXT NOT NULL,
gear TEXT NOT NULL,
materials TEXT NOT NULL,
buy_modifier INTEGER NOT NULL DEFAULT 100,
sell_modifier INTEGER NOT NULL DEFAULT 100,
created DATETIME DEFAULT CURRENT_TIMESTAMP,
updated DATETIME DEFAULT CURRENT_TIMESTAMP
)');
eln(yellow('Created table: ') . 'shops');
// Inns
$db->exec('DROP TABLE IF EXISTS inns');
$db->exec('CREATE TABLE inns (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
type INTEGER NOT NULL,
lore TEXT NOT NULL,
x INTEGER NOT NULL,
y INTEGER NOT NULL,
cost INTEGER NOT NULL,
created DATETIME DEFAULT CURRENT_TIMESTAMP,
updated DATETIME DEFAULT CURRENT_TIMESTAMP
)');
eln(yellow('Created table: ') . 'inns');
// Guilds
$db->exec('DROP TABLE IF EXISTS guilds');
$db->exec('CREATE TABLE guilds (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
lore TEXT NOT NULL DEFAULT "",
leader_id INTEGER NOT NULL,
silver INTEGER NOT NULL DEFAULT 0,
rep INTEGER NOT NULL DEFAULT 0,
created DATETIME DEFAULT CURRENT_TIMESTAMP,
updated DATETIME DEFAULT CURRENT_TIMESTAMP
)');
eln(yellow('Created table: ') . 'guilds');
// Guild ranks
$db->exec('DROP TABLE IF EXISTS guild_ranks');
$db->exec('CREATE TABLE guild_ranks (
guild_id INTEGER NOT NULL,
rank INTEGER NOT NULL,
name TEXT NOT NULL,
permissions TEXT NOT NULL
)');
eln(yellow('Created table: ') . 'guild_ranks');
// Guild members
$db->exec('DROP TABLE IF EXISTS guild_members');
$db->exec('CREATE TABLE guild_members (
guild_id INTEGER NOT NULL,
player_id INTEGER NOT NULL,
rank INTEGER NOT NULL,
rep INTEGER NOT NULL DEFAULT 0,
donated INTEGER NOT NULL DEFAULT 0,
joined DATETIME DEFAULT CURRENT_TIMESTAMP
)');
eln(yellow('Created table: ') . 'guild_members');
// NPCs
$db->exec('DROP TABLE IF EXISTS npcs');
$db->exec('CREATE TABLE npcs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
type INTEGER NOT NULL,
lore TEXT NOT NULL,
conversation TEXT NOT NULL,
x INTEGER NOT NULL,
y INTEGER NOT NULL,
created DATETIME DEFAULT CURRENT_TIMESTAMP,
updated DATETIME DEFAULT CURRENT_TIMESTAMP
)');
eln(yellow('Created table: ') . 'npcs');
// Town reputation
$db->exec('DROP TABLE IF EXISTS player_town_rep');
$db->exec('CREATE TABLE town_rep (
player_id INTEGER NOT NULL,
town_id INTEGER NOT NULL,
rep INTEGER NOT NULL DEFAULT 0
)');
eln(yellow('Created table: ') . 'town_rep');
// Items
// Items
$db->exec('DROP TABLE IF EXISTS items');
$db->exec('CREATE TABLE items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 0,
rarity INTEGER NOT NULL DEFAULT 0,
forged INTEGER NOT NULL DEFAULT 0,
quality INTEGER NOT NULL DEFAULT 0,
value INTEGER NOT NULL DEFAULT 0,
consumable INTEGER NOT NULL DEFAULT 0,
duration INTEGER NOT NULL DEFAULT 0,
durability INTEGER NOT NULL DEFAULT 0,
max_durability INTEGER NOT NULL DEFAULT 0,
power INTEGER NOT NULL DEFAULT 0,
toughness INTEGER NOT NULL DEFAULT 0,
armor INTEGER NOT NULL DEFAULT 0,
precision INTEGER NOT NULL DEFAULT 0,
crit INTEGER NOT NULL DEFAULT 0,
ferocity INTEGER NOT NULL DEFAULT 0,
vitality INTEGER NOT NULL DEFAULT 0,
reqs TEXT NOT NULL DEFAULT "",
traits TEXT NOT NULL DEFAULT "",
lore TEXT NOT NULL DEFAULT "",
created DATETIME DEFAULT CURRENT_TIMESTAMP,
updated DATETIME DEFAULT CURRENT_TIMESTAMP
)');
eln(yellow('Created table: ') . 'items');
eln(green('Created database: ') . 'live.db');
exit(0);
}

4
docs/TODO.md Normal file
View File

@ -0,0 +1,4 @@
# TODO
Currently, everything needs implemented.
First task is to finish building up the database structures.

3
docs/items.md Normal file
View File

@ -0,0 +1,3 @@
# Items
Items consists of all item types in the game; useless flavor items, gear, consumables, etc.

23
public/index.php Normal file
View File

@ -0,0 +1,23 @@
<?php
define('SRC', __DIR__ . '/../src');
require_once SRC . '/bootstrap.php';
$r = [];
router_get($r, '/', function () {
echo render('layouts/basic', ['view' => 'pages/home']);
});
router_get($r, '/auth/register', 'auth_register_get');
router_post($r, '/auth/register', 'auth_register_post');
router_get($r, '/auth/login', 'auth_login_get');
router_post($r, '/auth/login', 'auth_login_post');
router_post($r, '/auth/logout', 'auth_logout');
// [code, handler, params]
$l = router_lookup($r, $_SERVER['REQUEST_METHOD'], $_SERVER['REQUEST_URI']);
if ($l['code'] !== 200) router_error($l['code']);
$l['handler'](...$l['params'] ?? []);
clear_flashes();

206
src/auth.php Normal file
View File

@ -0,0 +1,206 @@
<?php
/**
* Checks if the given username already exists.
*/
function auth_usernameExists(string $username): bool
{
return db_exists(db_auth(), 'users', 'username', $username);
}
/**
* Checks if the given email already exists.
*/
function auth_emailExists(string $email): bool
{
return db_exists(db_auth(), 'users', 'email', $email);
}
/**
* Displays the registration page.
*/
function auth_register_get(): void
{
echo render('layouts/basic', ['view' => 'pages/auth/register']);
}
/**
* Handles the registration form submission.
*/
function auth_register_post(): void
{
csrf_ensure();
$errors = [];
$u = $_POST['username'] ?? '';
$e = $_POST['email'] ?? '';
$p = $_POST['password'] ?? '';
// Trim the input.
$u = trim($u);
$e = trim($e);
/*
A username is required.
A username must be at least 3 characters long and at most 25 characters long.
A username must contain only alphanumeric characters and spaces.
*/
if (empty($u) || strlen($u) < 3 || strlen($u) > 25 || !ctype_alnum(str_replace(' ', '', $u))) {
$errors['u'][] = 'Username is required and must be between 3 and 25 characters long and contain only
alphanumeric characters and spaces.';
}
/*
An email is required.
An email must be at most 255 characters long.
An email must be a valid email address.
*/
if (empty($e) || strlen($e) > 255 || !filter_var($e, FILTER_VALIDATE_EMAIL)) {
$errors['e'][] = 'Email is required must be a valid email address.';
}
/*
A password is required.
A password must be at least 6 characters long.
*/
if (empty($p) || strlen($p) < 6) {
$errors['p'][] = 'Password is required and must be at least 6 characters long.';
}
// If there are errors at this point, send them to the page with errors flashed.
if (!empty($errors)) {
flash('errors', $errors);
redirect('/auth/register');
}
/*
A username must be unique.
*/
if (auth_usernameExists($u)) {
$errors['u'][] = 'Username is already taken.';
}
/*
An email must be unique.
*/
if (auth_emailExists($e)) {
$errors['e'][] = 'Email is already taken.';
}
// If there are errors at this point, send them to the page with errors flashed.
if (!empty($errors)) {
flash('errors', $errors);
redirect('/auth/register');
}
$user = user_create($u, $e, $p);
if ($user === false) router_error(400);
$_SESSION['user'] = user_find($u);
redirect('/');
}
/**
* Displays the login page.
*/
function auth_login_get(): void
{
echo render('layouts/basic', ['view' => 'pages/auth/login']);
}
/**
* Handles the login form submission.
*/
function auth_login_post(): void
{
csrf_ensure();
$errors = [];
$u = $_POST['username'] ?? '';
$p = $_POST['password'] ?? '';
// Trim the input.
$u = trim($u);
/*
A username is required.
*/
if (empty($u)) {
$errors['u'][] = 'Username is required.';
}
/*
A password is required.
*/
if (empty($p)) {
$errors['p'][] = 'Password is required.';
}
// If there are errors at this point, send them to the page with errors flashed.
if (!empty($errors)) {
flash('errors', $errors);
redirect('/auth/login');
}
$user = user_find($u);
if ($user === false || !password_verify($p, $user['password'])) {
$errors['u'][] = 'Invalid username or password.';
flash('errors', $errors);
redirect('/auth/login');
}
$_SESSION['user'] = $user;
if ($_POST['remember'] ?? false) auth_rememberMe();
redirect('/');
}
/**
* Logs the user out.
*/
function auth_logout(): void
{
csrf_ensure();
session_delete($_SESSION['user']['id']);
unset($_SESSION['user']);
set_cookie('remember_me', '', 1);
redirect('/');
}
/**
* Create a long-lived session for the user.
*/
function auth_rememberMe()
{
$token = token();
$expires = strtotime('+30 days');
$result = db_query(db_auth(), "INSERT INTO sessions (token, user_id, expires) VALUES (:t, :u, :e)", [
':t' => $token,
':u' => $_SESSION['user']['id'],
':e' => $expires
]);
if (!$result) router_error(400);
set_cookie('remember_me', $token, $expires);
}
/**
* Check for a user session. If $_SESSION['user'] already exists, return early. If not, check for a remember me
* cookie. If a remember me cookie exists, validate the session and set $_SESSION['user'].
*/
function auth_check(): bool
{
if (isset($_SESSION['user'])) return true;
if (isset($_COOKIE['remember_me'])) {
$session = session_validate($_COOKIE['remember_me']);
if ($session === true) {
$user = user_find($session['user_id']);
unset($user['password']);
$_SESSION['user'] = user_find($session['user_id']);
return true;
}
}
return false;
}

34
src/bootstrap.php Normal file
View File

@ -0,0 +1,34 @@
<?php
session_start();
// SRC is defined as the path to the src/ directory from public/
// Source libraries
require_once SRC . '/helpers.php';
require_once SRC . '/env.php';
require_once SRC . '/database.php';
require_once SRC . '/auth.php';
require_once SRC . '/router.php';
// Database models
require_once SRC . '/models/user.php';
require_once SRC . '/models/session.php';
require_once SRC . '/models/token.php';
/*
Load env, set error reporting, etc.
*/
env_load(SRC . '/../.env');
if (env('debug') === 'true') {
ini_set('display_errors', '1');
ini_set('display_startup_errors', '1');
error_reporting(E_ALL);
}
// Generate a new CSRF token. (if one doesn't exist, that is)
csrf();
// Have a global counter for queries
$GLOBALS['queries'] = 0;

79
src/database.php Normal file
View File

@ -0,0 +1,79 @@
<?php
/**
* Return a connection to the auth database.
*/
function db_auth(): SQLite3
{
return $GLOBALS['db_auth'] ??= new SQLite3(__DIR__ . '/../database/auth.db');
}
/**
* Return a connection to the live database.
*/
function db_live(): SQLite3
{
return $GLOBALS['db_live'] ??= new SQLite3(__DIR__ . '/../database/live.db');
}
/**
* Return a connection to the fights database.
*/
function db_fights(): SQLite3
{
return $GLOBALS['db_fights'] ??= new SQLite3(__DIR__ . '/../database/fights.db');
}
/**
* Return a connection to the blueprints database.
*/
function db_blueprints(): SQLite3
{
return $GLOBALS['db_blueprints'] ??= new SQLite3(__DIR__ . '/../database/blueprints.db');
}
/**
* Take a SQLite3 database connection, a query string, and an array of parameters. Prepare the query and
* bind the parameters with proper type casting. Then execute the query and return the result.
*/
function db_query(SQLite3 $db, string $query, array $params = []): SQLite3Result|false
{
$stmt = $db->prepare($query);
if (!empty($params)) foreach ($params as $key => $value) $stmt->bindValue($key, $value, getSQLiteType($value));
$GLOBALS['queries']++;
return $stmt->execute();
}
/**
* Take a SQLite3 database connection and a query string. Execute the query and return the result.
*/
function db_exec(SQLite3 $db, string $query): bool
{
$GLOBALS['queries']++;
return $db->exec($query);
}
/**
* Take a SQLite3 database connection, a column name, and a value. Execute a COUNT query to see if the value
* exists in the column. Return true if the value exists, false otherwise.
*/
function db_exists(SQLite3 $db, string $table, string $column, mixed $value): bool
{
$result = db_query($db, "SELECT 1 FROM $table WHERE $column = :v LIMIT 1", [':v' => $value]);
return $result->fetchArray(SQLITE3_NUM) !== false;
}
/**
* Return the appropriate SQLite type casting for the value.
*/
function getSQLiteType(mixed $value): int
{
return match (true) {
is_int($value) => SQLITE3_INTEGER,
is_float($value) => SQLITE3_FLOAT,
is_null($value) => SQLITE3_NULL,
default => SQLITE3_TEXT
};
}

40
src/env.php Normal file
View File

@ -0,0 +1,40 @@
<?php
/**
* 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.");
$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
{
return $_ENV[$key] ?? $_SERVER[$key] ?? getenv($key) ?? $default;
}

110
src/helpers.php Normal file
View File

@ -0,0 +1,110 @@
<?php
/**
* Return the path to a view file.
*/
function template(string $name): string
{
return __DIR__ . "/../templates/$name.php";
}
/**
* Render a view with the given data. Looks for `$view` through `template()`.
*/
function render(string $pathToBaseView, array $data = []): string|false
{
ob_start();
extract($data);
require template($pathToBaseView);
return ob_get_clean();
}
/**
* Generate a pretty dope token.
*/
function token(int $length = 32): string
{
return bin2hex(random_bytes($length));
}
/**
* Redirect to a new location.
*/
function redirect(string $location): void
{
header("Location: $location");
exit;
}
/**
* Flash a message to the session, or retrieve an existing flash value.
*/
function flash(string $key, mixed $value = ''): mixed
{
if ($value === '') return $_SESSION["flash_$key"] ?? false;
$_SESSION["flash_$key"] = $value;
return $value;
}
/**
* Clear all flash messages.
*/
function clear_flashes(): void
{
foreach ($_SESSION as $key => $_) {
if (str_starts_with($key, 'flash_')) unset($_SESSION[$key]);
}
}
/**
* Create a CSRF token.
*/
function csrf(): string
{
if (empty($_SESSION['csrf'])) $_SESSION['csrf'] = token();
return $_SESSION['csrf'];
}
/**
* Verify a CSRF token.
*/
function csrf_verify(string $token): bool
{
if (hash_equals($_SESSION['csrf'] ?? '', $token)) {
$_SESSION['csrf'] = token();
return true;
}
return false;
}
/**
* Create a hidden input field for CSRF tokens.
*/
function csrf_field(): string
{
return '<input type="hidden" name="csrf" value="' . csrf() . '">';
}
/**
* Kill the current request with a 418 error, if $_POST['csrf'] is invalid.
*/
function csrf_ensure(): void
{
if (!csrf_verify($_POST['csrf'] ?? '')) router_error(418);
}
/**
* Set a cookie with secure and HTTP-only flags.
*/
function set_cookie(string $name, string $value, int $expires): void
{
setcookie($name, $value, [
'expires' => $expires,
'path' => '/',
'domain' => '', // Defaults to the current domain
'secure' => true, // Ensure the cookie is only sent over HTTPS
'httponly' => true, // Prevent access to cookie via JavaScript
'samesite' => 'Strict' // Enforce SameSite=Strict
]);
}

3
src/models/fights.php Normal file
View File

@ -0,0 +1,3 @@
<?php
// @TODO: everything

66
src/models/items.php Normal file
View File

@ -0,0 +1,66 @@
<?php
const item_types = [
0 => 'Item',
1 => 'Weapon', // Can be one-handed or two-handed, see slot
5 => 'Off-Hand',
6 => 'Armor',
5 => 'Shield',
10 => 'Jewelry',
11 => 'Rune',
12 => 'Potion',
13 => 'Food',
14 => 'Crafting Material',
15 => 'Quest Item',
];
const item_subtypes = [
0 => 'None',
1 => 'Axe',
2 => 'Bow',
3 => 'Dagger',
4 => 'Mace',
5 => 'Polearm',
6 => 'Sword',
7 => 'Warglaive',
8 => 'Staff',
9 => 'Fist Weapon',
10 => 'Miscellaneous',
11 => 'Gun',
12 => 'Crossbow',
13 => 'Wand',
14 => 'Fishing Pole',
15 => 'Thrown',
16 => 'Shield',
17 => 'Miscellaneous',
];
const item_rarities = [
0 => 'Common',
1 => 'Uncommon',
2 => 'Rare',
3 => 'Unique',
4 => 'Super Elite',
5 => 'Crystalline',
6 => 'Epic',
7 => 'Artifact',
8 => 'Heirloom',
9 => 'Legendary'
];
const item_qualities = [
0 => 'Very Poor',
1 => 'Poor',
2 => 'Average',
3 => 'Good',
4 => 'Very Good',
5 => 'Excellent',
6 => 'Masterwork',
];
/**
* Create an item
*/
function create_item(string $name, array $type, array $opts) {
}

37
src/models/player.php Normal file
View File

@ -0,0 +1,37 @@
<?php
/*
Players are the living, breathing entities that interact with the game world. They are inextricably linked to their
accounts, and are the primary means by which the player interacts with the game world. Separating the player from
the account allows for multiple players to be associated with a single account, and to prevent concurrency issues
when performing auth checks on the database.
When creating a player, we want to init all of the related data tables; wallets, inventory, bank, etc.
When retrieving a player, we will get the tables as-needed, to prevent allocating more memory than we need.
*/
/**
* Create a player. Only a user ID and a name are required. All other fields are optional. Pass a key-value array
* of overrides to set additional fields. A player's name must be unique, but this function does not check for that.
*/
function player_create(int $user_id, string $name, array $overrides = []): int
{
// Prep the data and merge in any overrides
$data = ['user_id' => $user_id, 'name' => $name];
if (!empty($overrides)) $data = array_merge($data, $overrides);
// Prep the fields for the query
$k = array_keys($data);
$f = implode(', ', array_keys($k));
$v = implode(', ', array_map(fn($x) => ":$x", $k));
// Create the player!
if (db_query(db_live(), "INSERT INTO players ($f) VALUES ($v)", $data) === false) {
// @TODO: Log this error
throw new Exception('Failed to create player.');
}
// Get the player ID
return db_live()->lastInsertRowID();
}

50
src/models/session.php Normal file
View File

@ -0,0 +1,50 @@
<?php
/**
* Create a session for a user with a token and expiration date. Returns the token on success, or false on failure.
*/
function session_create(int $userId, int $expires): string|false
{
$token = token();
$result = db_query(db_auth(), "INSERT INTO sessions (token, user_id, expires) VALUES (:t, :u, :e)", [
':t' => $token,
':u' => $userId,
':e' => $expires
]);
if (!$result) return false;
return $token;
}
/**
* Find a session by token.
*/
function session_find(string $token): array|false
{
$result = db_query(db_auth(), "SELECT * FROM sessions WHERE token = :t", [':t' => $token]);
$session = $result->fetchArray(SQLITE3_ASSOC);
if (!$session) return false;
$result->finalize();
return $session;
}
/**
* Delete sessions by user id.
*/
function session_delete(int $userId): SQLite3Result|false
{
return db_query(db_auth(), "DELETE FROM sessions WHERE user_id = :u", [':u' => $userId]);
}
/**
* Validate a session by token and expiration date. If expired, the session is deleted and false is returned.
*/
function session_validate(string $token): bool
{
$session = session_find($token);
if (!$session) return false;
if ($session['expires'] < time()) {
session_delete($session['user_id']);
return false;
}
return true;
}

50
src/models/token.php Normal file
View File

@ -0,0 +1,50 @@
<?php
/**
* Create a token for a user. Returns the token on success, or false on failure.
*/
function token_create(int $userId): string|false
{
$token = token();
$result = db_query(db_auth(), "INSERT INTO tokens (token, user_id) VALUES (:t, :u)", [
':t' => $token,
':u' => $userId
]);
if (!$result) return false;
return $token;
}
/**
* Find a token by token.
*/
function token_find(string $token): array|false
{
$result = db_query(db_auth(), "SELECT * FROM tokens WHERE token = :t", [':t' => $token]);
$token = $result->fetchArray(SQLITE3_ASSOC);
if (!$token) return false;
$result->finalize();
return $token;
}
/**
* Delete a token by token.
*/
function token_delete(string $token): SQLite3Result|false
{
return db_query(db_auth(), "DELETE FROM tokens WHERE token = :t", [':t' => $token]);
}
/**
* Validate a token by token and created date. Tokens are invalid if older than 7 days.
*/
function token_validate(string $token): bool
{
$token = token_find($token);
if (!$token) return false;
if (strtotime('+7 days') < time()) {
token_delete($token['token']);
return false;
}
return true;
}

37
src/models/user.php Normal file
View File

@ -0,0 +1,37 @@
<?php
/**
* Find a user by username, email, or id.
*/
function user_find(string|int $user): array|false
{
$result = db_query(db_auth(), "SELECT * FROM users WHERE username = :u OR email = :u OR id = :u", [':u' => $user]);
$user = $result->fetchArray(SQLITE3_ASSOC);
if (!$user) return false;
$result->finalize();
return $user;
}
/**
* Create a user with a username, email, and password. Optionally pass an auth level. This function will not check
* if the username or email already exists. It is up to the caller to check this before calling this function. It is
* also up to the caller to validate password strength. This function will hash the password with the PASSWORD_ARGON2ID
* algorithm.
*/
function user_create(string $username, string $email, string $password, int $auth = 0): SQLite3Result|false
{
return db_query(db_auth(), "INSERT INTO users (username, email, password, auth) VALUES (:u, :e, :p, :a)", [
':u' => $username,
':e' => $email,
':p' => password_hash($password, PASSWORD_ARGON2ID),
':a' => $auth
]);
}
/**
* Delete a user by username, email, or id.
*/
function user_delete(string|int $user): SQLite3Result|false
{
return db_query(db_auth(), "DELETE FROM users WHERE username = :u OR email = :u OR id = :u", [':u' => $user]);
}

130
src/router.php Normal file
View File

@ -0,0 +1,130 @@
<?php
/**
* Add a route to the route tree. The route must be a URI path, and contain dynamic segments
* using a colon prefix. (:id, :slug, etc)
*
* Example:
* `router_add($routes, 'GET', '/posts/:id', function($id) { echo "Viewing post $id"; });`
*/
function router_add(array &$routes, string $method, string $route, callable $handler): void
{
// Expand the route into segments and make dynamic segments into a common placeholder
$segments = array_map(function($segment) {
return str_starts_with($segment, ':') ? ':x' : $segment;
}, explode('/', trim($route, '/')));
// Push each segment into the routes array as a node, except if this is the root node
$node = &$routes;
foreach ($segments as $segment) {
// skip an empty segment, which allows us to register handlers for the root node
if ($segment === '') continue;
$node = &$node[$segment]; // build the node tree as we go
}
// Add the handler to the last node
$node[$method] = $handler;
}
/**
* 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']
*/
function router_lookup(array $routes, string $method, string $uri): array
{
// node is a reference to our current location in the node tree
$node = $routes;
// params will hold any dynamic segments we find
$params = [];
// if the URI is just a slash, we can return the handler for the root node
if ($uri === '/') {
return isset($node[$method])
? ['code' => 200, 'handler' => $node[$method], 'params' => null]
: ['code' => 405, 'handler' => null, 'params' => null];
}
// We'll split up the URI into segments and traverse the node tree
foreach (explode('/', trim($uri, '/')) as $segment) {
// if there is a node for this segment, move to it
if (isset($node[$segment])) {
$node = $node[$segment];
continue;
}
// if there is a dynamic segment, move to it and store the value
if (isset($node[':x'])) {
$params[] = $segment;
$node = $node[':x'];
continue;
}
// if we can't find a node for this segment, return 404
return ['code' => 404, 'handler' => null, 'params' => []];
}
// if we found a handler for the method, return it and any params. if not, return a 405
return isset($node[$method])
? ['code' => 200, 'handler' => $node[$method], 'params' => $params ?? []]
: ['code' => 405, 'handler' => null, 'params' => []];
}
/**
* Register a GET route
*/
function router_get(array &$routes, string $route, callable $handler): void
{
router_add($routes, 'GET', $route, $handler);
}
/**
* Register a POST route
*/
function router_post(array &$routes, string $route, callable $handler): void
{
router_add($routes, 'POST', $route, $handler);
}
/**
* Register a PUT route
*/
function router_put(array &$routes, string $route, callable $handler): void
{
router_add($routes, 'PUT', $route, $handler);
}
/**
* Register a DELETE route
*/
function router_delete(array &$routes, string $route, callable $handler): void
{
router_add($routes, 'DELETE', $route, $handler);
}
/**
* Register a PATCH route
*/
function router_patch(array &$routes, string $route, callable $handler): void
{
router_add($routes, 'PATCH', $route, $handler);
}
/**
* Handle a router error by setting the response code and echoing an error message
*/
function router_error(int $code): void
{
http_response_code($code);
echo match ($code) {
403 => 'Forbidden',
404 => 'Not Found',
405 => 'Method Not Allowed',
418 => 'I\'m a teapot',
default => 'Unknown Error',
};
exit;
}

View File

@ -0,0 +1,23 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dragon Knight</title>
</head>
<body>
<div id="dk">
<header>
<h1>Dragon Knight</h1>
</header>
<main>
<?= render($view, $data) ?>
</main>
<footer>
<p>&copy; 2024 Dragon Knight</p>
</footer>
</div>
</body>
</html>

View File

@ -0,0 +1,19 @@
<?php
$errors = flash('errors');
if ($errors !== false) {
foreach ($errors as $error) {
foreach ($error as $message) {
echo "<p>$message</p>";
}
}
}
?>
<form action="/auth/login" method="post">
<?= csrf_field() ?>
<input type="text" name="username" placeholder="Username">
<input type="password" name="password" placeholder="Password">
<input type="checkbox" name="remember" id="remember"> <label for="remember">remember me</label>
<input type="submit" value="Login">
</form>

View File

@ -0,0 +1,19 @@
<?php
$errors = flash('errors');
if ($errors !== false) {
foreach ($errors as $error) {
foreach ($error as $message) {
echo "<p>$message</p>";
}
}
}
?>
<form action="/auth/register" method="post">
<?= csrf_field() ?>
<input type="text" name="username" placeholder="Username">
<input type="text" name="email" placeholder="Email">
<input type="password" name="password" placeholder="Password">
<input type="submit" value="Register">
</form>

11
templates/pages/home.php Normal file
View File

@ -0,0 +1,11 @@
<?php if (!auth_check()): ?>
Hello, oppai!
<a href="/auth/register">Register</a>
<a href="/auth/login">Login</a>
<?php else: ?>
Hello, <?= $_SESSION['user']['username'] ?>!
<form action="/auth/logout" method="post">
<input type="hidden" name="csrf" value="<?= csrf() ?>">
<input type="submit" value="Logout">
</form>
<?php endif; ?>