2025-08-27 17:36:06 -04:00
|
|
|
use std::path::Path;
|
2025-08-29 18:11:17 -04:00
|
|
|
use std::str::FromStr;
|
|
|
|
|
|
|
|
|
|
use sqlx::sqlite::{SqliteConnectOptions, SqlitePool, SqlitePoolOptions};
|
|
|
|
|
use sqlx::{Error, Result};
|
2025-08-27 17:36:06 -04:00
|
|
|
|
|
|
|
|
#[cfg(not(feature = "publisher"))]
|
2025-08-29 18:11:17 -04:00
|
|
|
use sqlx::Row;
|
2025-08-27 17:36:06 -04:00
|
|
|
|
2025-08-29 21:08:04 -04:00
|
|
|
use crate::adventurer::Adventurer;
|
2025-08-29 18:11:17 -04:00
|
|
|
use crate::tale::Tale;
|
|
|
|
|
|
2025-08-27 17:36:06 -04:00
|
|
|
#[cfg(feature = "publisher")]
|
|
|
|
|
use crate::converter::Converter;
|
2025-08-29 22:50:19 -04:00
|
|
|
#[cfg(feature = "publisher")]
|
|
|
|
|
use crate::tavern::Tavern;
|
2025-08-29 18:11:17 -04:00
|
|
|
|
2025-08-27 17:36:06 -04:00
|
|
|
#[cfg(not(feature = "publisher"))]
|
2025-08-29 19:09:05 -04:00
|
|
|
use crate::tale::Lore;
|
2025-08-29 21:08:04 -04:00
|
|
|
#[cfg(not(feature = "publisher"))]
|
|
|
|
|
use crate::adventurer::Legend;
|
2025-08-27 17:36:06 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
2025-08-29 18:11:17 -04:00
|
|
|
/// Represents the database connection pool.
|
2025-08-27 17:36:06 -04:00
|
|
|
pub struct Database
|
|
|
|
|
{
|
2025-08-29 18:11:17 -04:00
|
|
|
pool: SqlitePool
|
2025-08-27 17:36:06 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
impl Database
|
|
|
|
|
{
|
2025-08-29 18:11:17 -04:00
|
|
|
/// Opens a connection to the SQLite database pool and creates the necessary
|
2025-08-27 17:36:06 -04:00
|
|
|
/// tables.
|
2025-08-29 18:11:17 -04:00
|
|
|
///
|
|
|
|
|
/// db_path is an absolute path to the resource file.
|
|
|
|
|
/// Example:
|
|
|
|
|
/// ```text
|
|
|
|
|
/// open("/var/website/tavern.db");
|
|
|
|
|
/// ```
|
|
|
|
|
pub async fn open<P>(db_path: P) -> Result<Self>
|
|
|
|
|
where P: AsRef<Path>
|
2025-08-27 17:36:06 -04:00
|
|
|
{
|
2025-08-29 18:11:17 -04:00
|
|
|
let db_str =
|
|
|
|
|
db_path.as_ref().to_str().ok_or_else(|| {
|
|
|
|
|
Error::Configuration("Invalid UTF-8 in database \
|
|
|
|
|
path"
|
|
|
|
|
.into())
|
|
|
|
|
})?;
|
2025-08-27 17:36:06 -04:00
|
|
|
|
2025-08-29 18:11:17 -04:00
|
|
|
let url: String = format!("sqlite:///{db_str}");
|
|
|
|
|
|
|
|
|
|
// Set up connection options with foreign keys enabled.
|
2025-08-27 17:36:06 -04:00
|
|
|
#[cfg(feature = "publisher")]
|
2025-08-29 18:11:17 -04:00
|
|
|
let connect_options =
|
|
|
|
|
SqliteConnectOptions::from_str(&url)?.read_only(false)
|
|
|
|
|
.foreign_keys(true)
|
|
|
|
|
.create_if_missing(true);
|
|
|
|
|
|
|
|
|
|
#[cfg(not(feature = "publisher"))]
|
|
|
|
|
let connect_options =
|
|
|
|
|
SqliteConnectOptions::from_str(&url)?.read_only(true)
|
|
|
|
|
.foreign_keys(true)
|
|
|
|
|
.create_if_missing(false);
|
|
|
|
|
|
|
|
|
|
let pool = SqlitePoolOptions::new().connect_with(connect_options)
|
|
|
|
|
.await?;
|
2025-08-27 17:36:06 -04:00
|
|
|
|
2025-08-29 18:11:17 -04:00
|
|
|
let database = Database { pool };
|
|
|
|
|
|
|
|
|
|
#[cfg(feature = "publisher")]
|
|
|
|
|
database.create_tables().await?;
|
|
|
|
|
|
|
|
|
|
Ok(database)
|
2025-08-27 17:36:06 -04:00
|
|
|
}
|
|
|
|
|
|
2025-08-29 18:11:17 -04:00
|
|
|
/// Creates the 'tales', 'adventurers', 'tags', 'tale_tags', and 'tavern'
|
|
|
|
|
/// tables if they don't exist.
|
2025-08-27 17:36:06 -04:00
|
|
|
#[cfg(feature = "publisher")]
|
2025-08-29 18:11:17 -04:00
|
|
|
async fn create_tables(&self) -> Result<()>
|
2025-08-27 17:36:06 -04:00
|
|
|
{
|
2025-08-29 18:11:17 -04:00
|
|
|
sqlx::query!(
|
2025-08-27 17:36:06 -04:00
|
|
|
"CREATE TABLE IF NOT EXISTS adventurers (
|
|
|
|
|
handle TEXT PRIMARY KEY,
|
|
|
|
|
name TEXT NOT NULL,
|
2025-08-29 18:11:17 -04:00
|
|
|
profile TEXT NOT NULL,
|
|
|
|
|
image TEXT NOT NULL,
|
|
|
|
|
blurb TEXT NOT NULL
|
|
|
|
|
)"
|
|
|
|
|
).execute(&self.pool)
|
|
|
|
|
.await?;
|
|
|
|
|
|
|
|
|
|
sqlx::query!(
|
2025-08-27 17:36:06 -04:00
|
|
|
"CREATE TABLE IF NOT EXISTS tales (
|
|
|
|
|
slug TEXT PRIMARY KEY,
|
|
|
|
|
title TEXT NOT NULL,
|
|
|
|
|
author TEXT NOT NULL,
|
2025-08-29 18:11:17 -04:00
|
|
|
summary TEXT NOT NULL,
|
2025-08-27 17:36:06 -04:00
|
|
|
publish_date TEXT NOT NULL,
|
2025-08-29 18:11:17 -04:00
|
|
|
content TEXT NOT NULL
|
|
|
|
|
)"
|
|
|
|
|
).execute(&self.pool)
|
|
|
|
|
.await?;
|
|
|
|
|
|
|
|
|
|
sqlx::query!(
|
2025-08-27 17:36:06 -04:00
|
|
|
"CREATE TABLE IF NOT EXISTS tags (
|
|
|
|
|
id INTEGER PRIMARY KEY,
|
|
|
|
|
name TEXT NOT NULL UNIQUE
|
2025-08-29 18:11:17 -04:00
|
|
|
)"
|
|
|
|
|
).execute(&self.pool)
|
|
|
|
|
.await?;
|
|
|
|
|
|
|
|
|
|
sqlx::query!(
|
2025-08-27 17:36:06 -04:00
|
|
|
"CREATE TABLE IF NOT EXISTS tale_tags (
|
|
|
|
|
tale_slug TEXT,
|
|
|
|
|
tag_id INTEGER,
|
|
|
|
|
FOREIGN KEY(tale_slug) REFERENCES tales(slug),
|
|
|
|
|
FOREIGN KEY(tag_id) REFERENCES tags(id),
|
|
|
|
|
UNIQUE(tale_slug, tag_id)
|
2025-08-29 18:11:17 -04:00
|
|
|
)"
|
|
|
|
|
).execute(&self.pool)
|
|
|
|
|
.await?;
|
|
|
|
|
|
|
|
|
|
sqlx::query!(
|
|
|
|
|
"CREATE TABLE IF NOT EXISTS tavern (
|
2025-08-27 17:36:06 -04:00
|
|
|
key TEXT PRIMARY KEY,
|
|
|
|
|
value TEXT NOT NULL
|
2025-08-29 18:11:17 -04:00
|
|
|
)"
|
|
|
|
|
).execute(&self.pool)
|
|
|
|
|
.await?;
|
|
|
|
|
|
2025-08-27 17:36:06 -04:00
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Inserts a single tale into the database.
|
|
|
|
|
#[cfg(feature = "publisher")]
|
2025-08-29 18:11:17 -04:00
|
|
|
pub async fn insert_tale(&self, tale: &Tale)
|
2025-08-29 22:50:19 -04:00
|
|
|
-> Result<()>
|
2025-08-27 17:36:06 -04:00
|
|
|
{
|
2025-08-29 18:11:17 -04:00
|
|
|
// Convert the tales content from Markdown to HTML.
|
2025-08-29 19:09:05 -04:00
|
|
|
let markdown_content = std::fs::read_to_string(&tale.story)?;
|
2025-08-29 18:11:17 -04:00
|
|
|
let html_content: String = Converter::markdown_to_html(&markdown_content);
|
|
|
|
|
|
|
|
|
|
// Start a transaction.
|
|
|
|
|
let mut tx = self.pool.begin().await?;
|
|
|
|
|
|
|
|
|
|
// Store the tale.
|
|
|
|
|
sqlx::query!(
|
|
|
|
|
"INSERT OR REPLACE INTO tales (
|
|
|
|
|
slug, title, author, summary, publish_date, content
|
|
|
|
|
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
2025-08-29 19:09:05 -04:00
|
|
|
tale.lore.slug,
|
|
|
|
|
tale.lore.title,
|
|
|
|
|
tale.lore.author,
|
|
|
|
|
tale.lore.summary,
|
|
|
|
|
tale.lore.publish_date,
|
2025-08-29 18:11:17 -04:00
|
|
|
html_content
|
|
|
|
|
).execute(&mut *tx) // Pass mutable reference to the transaction
|
|
|
|
|
.await?;
|
|
|
|
|
|
|
|
|
|
// Store the tags.
|
|
|
|
|
// For each tag ...
|
2025-08-29 19:09:05 -04:00
|
|
|
for tag_name in &tale.lore.tags
|
2025-08-27 17:36:06 -04:00
|
|
|
{
|
2025-08-29 18:11:17 -04:00
|
|
|
// Insert a new tag, ignore if it already exists.
|
|
|
|
|
sqlx::query!("INSERT OR IGNORE INTO tags (name) VALUES (?1)",
|
|
|
|
|
tag_name).execute(&mut *tx) // Pass mutable reference to the transaction
|
|
|
|
|
.await?;
|
|
|
|
|
|
|
|
|
|
// Get the tag_id for the newly inserted or existing tag.
|
|
|
|
|
let id: i64 = sqlx::query!("SELECT id FROM tags WHERE name = ?1",
|
|
|
|
|
tag_name).fetch_one(&mut *tx) // Pass mutable reference to the transaction
|
|
|
|
|
.await?
|
|
|
|
|
.id
|
|
|
|
|
.unwrap_or(0); // Use unwrap_or to handle the Option<i64>
|
|
|
|
|
|
|
|
|
|
// Insert the tale_tag relationship.
|
|
|
|
|
sqlx::query!(
|
|
|
|
|
"INSERT OR IGNORE INTO tale_tags (tale_slug, tag_id)
|
|
|
|
|
VALUES (?1, ?2)",
|
2025-08-29 19:09:05 -04:00
|
|
|
tale.lore.slug,
|
2025-08-29 18:11:17 -04:00
|
|
|
id
|
|
|
|
|
).execute(&mut *tx) // Pass mutable reference to the transaction
|
|
|
|
|
.await?;
|
2025-08-27 17:36:06 -04:00
|
|
|
}
|
|
|
|
|
|
2025-08-29 18:11:17 -04:00
|
|
|
// Commit the transaction.
|
|
|
|
|
tx.commit().await?;
|
|
|
|
|
|
2025-08-27 17:36:06 -04:00
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-29 18:11:17 -04:00
|
|
|
/// Inserts a single adventurer into the database.
|
2025-08-27 17:36:06 -04:00
|
|
|
#[cfg(feature = "publisher")]
|
2025-08-29 18:11:17 -04:00
|
|
|
pub async fn insert_adventurer(&self, adventurer: &Adventurer)
|
|
|
|
|
-> Result<()>
|
2025-08-27 17:36:06 -04:00
|
|
|
{
|
2025-08-29 18:11:17 -04:00
|
|
|
// Start a transaction.
|
|
|
|
|
let mut tx = self.pool.begin().await?;
|
|
|
|
|
|
|
|
|
|
sqlx::query!(
|
|
|
|
|
"INSERT OR REPLACE INTO adventurers (
|
|
|
|
|
handle, name, profile, image, blurb
|
|
|
|
|
) VALUES (?1, ?2, ?3, ?4, ?5)",
|
|
|
|
|
adventurer.handle,
|
|
|
|
|
adventurer.name,
|
2025-08-29 19:09:05 -04:00
|
|
|
adventurer.legend.profile,
|
|
|
|
|
adventurer.legend.image,
|
|
|
|
|
adventurer.legend.blurb
|
2025-08-29 18:11:17 -04:00
|
|
|
).execute(&mut *tx)
|
|
|
|
|
.await?;
|
|
|
|
|
|
|
|
|
|
// Commit the transaction.
|
|
|
|
|
tx.commit().await?;
|
|
|
|
|
|
|
|
|
|
Ok(())
|
2025-08-27 17:36:06 -04:00
|
|
|
}
|
|
|
|
|
|
2025-08-29 18:11:17 -04:00
|
|
|
/// Inserts the site-wide settings like title and description.
|
2025-08-27 17:36:06 -04:00
|
|
|
#[cfg(feature = "publisher")]
|
2025-08-29 22:50:19 -04:00
|
|
|
pub async fn insert_tavern_settings(&self, title: &str, description: &str)
|
2025-08-29 18:11:17 -04:00
|
|
|
-> Result<()>
|
2025-08-27 17:36:06 -04:00
|
|
|
{
|
2025-08-29 18:11:17 -04:00
|
|
|
// Start a transaction.
|
|
|
|
|
let mut tx = self.pool.begin().await?;
|
|
|
|
|
|
|
|
|
|
// Insert or replace the title.
|
|
|
|
|
sqlx::query!("INSERT OR REPLACE INTO tavern (key, value) VALUES \
|
|
|
|
|
('title', ?1)",
|
|
|
|
|
title).execute(&mut *tx) // Pass mutable reference to the transaction
|
|
|
|
|
.await?;
|
|
|
|
|
|
|
|
|
|
// Insert or replace the description.
|
|
|
|
|
sqlx::query!("INSERT OR REPLACE INTO tavern (key, value) VALUES \
|
|
|
|
|
('description', ?1)",
|
|
|
|
|
description).execute(&mut *tx) // Pass mutable reference to the transaction
|
|
|
|
|
.await?;
|
|
|
|
|
|
|
|
|
|
// Commit the transaction.
|
|
|
|
|
tx.commit().await?;
|
|
|
|
|
|
2025-08-27 17:36:06 -04:00
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-29 22:50:19 -04:00
|
|
|
/// Inserts a Tavern into the database.
|
|
|
|
|
#[cfg(feature = "publisher")]
|
|
|
|
|
pub async fn insert_tavern(&self, tavern: &Tavern) -> Result<()>
|
|
|
|
|
{
|
|
|
|
|
self.insert_tavern_settings(&tavern.title, &tavern.description).await?;
|
|
|
|
|
|
|
|
|
|
for tale in &tavern.tales
|
|
|
|
|
{
|
|
|
|
|
self.insert_tale(tale).await?;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for author in &tavern.authors
|
|
|
|
|
{
|
|
|
|
|
self.insert_adventurer(author).await?;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-29 18:11:17 -04:00
|
|
|
#[cfg(not(feature = "publisher"))]
|
|
|
|
|
pub async fn get_tales_summary(&self, categories: &[String])
|
2025-08-29 19:09:05 -04:00
|
|
|
-> Result<Vec<Lore>>
|
2025-08-29 18:11:17 -04:00
|
|
|
{
|
|
|
|
|
let mut tales = Vec::new();
|
|
|
|
|
|
|
|
|
|
// Start a read-only transaction.
|
|
|
|
|
let mut tx = self.pool.begin().await?;
|
|
|
|
|
|
|
|
|
|
// Dynamically build the query.
|
|
|
|
|
let mut query = String::from(
|
|
|
|
|
"SELECT
|
|
|
|
|
t.title,
|
|
|
|
|
t.slug,
|
|
|
|
|
t.summary,
|
|
|
|
|
t.author,
|
|
|
|
|
t.publish_date,
|
|
|
|
|
GROUP_CONCAT(tg.name, ',') AS tags
|
|
|
|
|
FROM tales AS t
|
|
|
|
|
LEFT JOIN tale_tags AS tt ON t.slug = tt.tale_slug
|
|
|
|
|
LEFT JOIN tags AS tg ON tt.tag_id = tg.id"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if !categories.is_empty()
|
|
|
|
|
{
|
|
|
|
|
query.push_str(" WHERE tg.name IN (");
|
|
|
|
|
let placeholders: Vec<_> =
|
|
|
|
|
(0..categories.len()).map(|_| "?").collect();
|
|
|
|
|
query.push_str(&placeholders.join(", "));
|
|
|
|
|
query.push(')');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
query.push_str(" GROUP BY t.slug ORDER BY t.publish_date DESC");
|
|
|
|
|
|
|
|
|
|
let mut q = sqlx::query(&query);
|
|
|
|
|
for cat in categories
|
|
|
|
|
{
|
|
|
|
|
q = q.bind(cat);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let rows = q.fetch_all(&mut *tx).await?;
|
|
|
|
|
|
|
|
|
|
for row in rows
|
|
|
|
|
{
|
|
|
|
|
let tags_str: Option<String> = row.try_get("tags")?;
|
|
|
|
|
let tags = tags_str.map(|s| s.split(',').map(String::from).collect())
|
|
|
|
|
.unwrap_or_default();
|
|
|
|
|
|
|
|
|
|
let date_str: String = row.try_get("publish_date")?;
|
|
|
|
|
let publish_date = chrono::NaiveDateTime::parse_from_str(&date_str, "%Y-%m-%dT%H:%M:%S")
|
|
|
|
|
.map_err(|e| sqlx::Error::Decode(e.into()))?;
|
|
|
|
|
|
2025-08-29 19:09:05 -04:00
|
|
|
tales.push(Lore { title: row.try_get("title")?,
|
2025-08-29 18:11:17 -04:00
|
|
|
slug: row.try_get("slug")?,
|
|
|
|
|
summary: row.try_get("summary")?,
|
|
|
|
|
author: row.try_get("author")?,
|
|
|
|
|
publish_date,
|
|
|
|
|
tags });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
tx.commit().await?; // Explicit commit, even for read transactions.
|
|
|
|
|
|
|
|
|
|
Ok(tales)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(not(feature = "publisher"))]
|
|
|
|
|
pub async fn get_tale_by_slug(&self, slug: &str) -> Result<Option<Tale>>
|
|
|
|
|
{
|
|
|
|
|
let mut tx = self.pool.begin().await?;
|
|
|
|
|
|
|
|
|
|
let tale_row = sqlx::query(
|
|
|
|
|
"SELECT
|
|
|
|
|
t.title, t.slug, t.summary, t.author, t.publish_date,
|
|
|
|
|
t.content,
|
|
|
|
|
GROUP_CONCAT(tg.name, ',') AS tags
|
|
|
|
|
FROM tales AS t
|
|
|
|
|
LEFT JOIN tale_tags AS tt ON t.slug = tt.tale_slug
|
|
|
|
|
LEFT JOIN tags AS tg ON tt.tag_id = tg.id
|
|
|
|
|
WHERE t.slug = ?1
|
|
|
|
|
GROUP BY t.slug"
|
|
|
|
|
).bind(slug)
|
|
|
|
|
.fetch_optional(&mut *tx) // Use transaction here
|
|
|
|
|
.await?;
|
|
|
|
|
|
|
|
|
|
tx.commit().await?;
|
|
|
|
|
|
|
|
|
|
if let Some(row) = tale_row
|
|
|
|
|
{
|
|
|
|
|
let tags_str: Option<String> = row.try_get("tags")?;
|
|
|
|
|
let tags = tags_str.map(|s| s.split(',').map(String::from).collect())
|
|
|
|
|
.unwrap_or_default();
|
|
|
|
|
|
|
|
|
|
let date_str: String = row.try_get("publish_date")?;
|
|
|
|
|
let publish_date = chrono::NaiveDateTime::parse_from_str(&date_str, "%Y-%m-%dT%H:%M:%S")
|
|
|
|
|
.map_err(|e| Error::Decode(e.into()))?;
|
|
|
|
|
|
2025-08-29 19:09:05 -04:00
|
|
|
let lore = Lore { title: row.try_get("title")?,
|
2025-08-29 18:11:17 -04:00
|
|
|
slug: row.try_get("slug")?,
|
|
|
|
|
summary: row.try_get("summary")?,
|
|
|
|
|
author: row.try_get("author")?,
|
|
|
|
|
publish_date,
|
|
|
|
|
tags };
|
|
|
|
|
|
2025-08-29 19:09:05 -04:00
|
|
|
Ok(Some(Tale { lore,
|
|
|
|
|
story: row.try_get("content")? }))
|
2025-08-29 18:11:17 -04:00
|
|
|
}
|
|
|
|
|
else
|
|
|
|
|
{
|
|
|
|
|
Ok(None)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(not(feature = "publisher"))]
|
|
|
|
|
pub async fn get_adventurer(&self, handle: &str)
|
|
|
|
|
-> Result<Option<Adventurer>>
|
|
|
|
|
{
|
|
|
|
|
let mut tx = self.pool.begin().await?;
|
|
|
|
|
|
2025-08-29 19:09:05 -04:00
|
|
|
let legend = sqlx::query_as!(
|
|
|
|
|
Legend,
|
|
|
|
|
"SELECT
|
|
|
|
|
profile AS profile,
|
|
|
|
|
image AS image,
|
|
|
|
|
blurb AS blurb
|
|
|
|
|
FROM adventurers
|
|
|
|
|
WHERE handle = ?1",
|
|
|
|
|
handle
|
|
|
|
|
)
|
|
|
|
|
.fetch_optional(&mut *tx)
|
|
|
|
|
.await?;
|
|
|
|
|
|
|
|
|
|
let hero = sqlx::query!(
|
|
|
|
|
"SELECT
|
|
|
|
|
name,
|
|
|
|
|
handle AS 'handle!'
|
|
|
|
|
FROM adventurers
|
|
|
|
|
WHERE handle = ?1",
|
|
|
|
|
handle
|
|
|
|
|
)
|
|
|
|
|
.fetch_optional(&mut *tx)
|
|
|
|
|
.await?;
|
2025-08-29 18:11:17 -04:00
|
|
|
|
|
|
|
|
tx.commit().await?;
|
|
|
|
|
|
2025-08-29 19:09:05 -04:00
|
|
|
let adventurer = match (hero, legend)
|
|
|
|
|
{
|
|
|
|
|
(Some(h), Some(l)) =>
|
|
|
|
|
{
|
|
|
|
|
Some(Adventurer
|
|
|
|
|
{
|
|
|
|
|
name: h.name,
|
|
|
|
|
handle: h.handle,
|
|
|
|
|
legend: l,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_ => { None }
|
|
|
|
|
};
|
|
|
|
|
|
2025-08-29 18:11:17 -04:00
|
|
|
Ok(adventurer)
|
|
|
|
|
}
|
2025-08-27 17:36:06 -04:00
|
|
|
}
|