User accounts, login before the title screen, own savegames per user, standalone download

- Service: accounts with Argon2 password hashes and session tokens; the first login creates
  the admin (shared savegames of before move to the admin); the admin creates and deletes
  users; everybody can change the password; savegames are stored and listed per user
- Game: login page before the title (only when the service is reachable), with licence and
  repository link; account page (password, logout; users for the admin); text fields with
  Tab/Enter, masked passwords, browser dialog on touch screens; long status messages wrap
- Download Standalone: DungeonCraft.html with the game embedded (built in the Docker image,
  served to logged-in users via nginx auth_request); works from file:// without server and
  opens downloaded savegames
- Without the service (standalone, local files) there is no login, savegame files as before

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-03 13:33:22 +01:00
1 parent 8853d84564
commit c9848c8948
15 files changed
+1232 -83

No files matched your search

+140 -9
View File
@@ -153,6 +153,61 @@ fn store_progress(p: &campaign::Progress) {
platform::storage_set(PROGRESS_KEY, &serde_json::to_string(p).unwrap_or_default());
}
// login, account and standalone actions of the menu
fn account_action(a: MenuAction, cloud: &mut Cloud, menu: &mut Menu, app: &mut App, now: f64) {
match a {
MenuAction::Login(name, pw) => {
if name.is_empty() || pw.is_empty() {
menu.error = Some(i18n::tr("login.err_invalid"));
} else if cloud.login(&name, &pw) {
menu.error = Some(i18n::tr("saves.wait"));
}
}
MenuAction::Logout => {
cloud.logout();
menu.open_login();
menu.error = None;
}
MenuAction::OpenAccount => {
if cloud.session.as_ref().map_or(false, |s| s.admin) {
cloud.load_users();
}
}
MenuAction::ChangePassword(old, new) => {
if new.chars().count() < 4 {
menu.error = Some(i18n::tr("login.err_invalid"));
} else {
cloud.change_password(&old, &new);
}
}
MenuAction::CreateUser(name, pw) => {
if name.is_empty() || pw.chars().count() < 4 {
menu.error = Some(i18n::tr("login.err_invalid"));
} else {
cloud.create_user(&name, &pw);
}
}
MenuAction::DeleteUser(name) => {
cloud.delete_user(&name);
}
MenuAction::DownloadStandalone => {
platform::fetch_download("standalone/DungeonCraft.html", "DungeonCraft.html");
app.set_status(i18n::tr("standalone.running"), now);
}
MenuAction::EditField(i) => {
// touch screens have no keyboard in the canvas: the browser asks
if ui::touch_ui() {
let secret = matches!((menu.page, i), (Some(Page::Login), 1) | (Some(Page::Account), 0 | 1 | 3));
let title = i18n::tr(if secret { "login.password" } else { "login.name" });
if let Some(t) = platform::prompt(&title, if secret { "" } else { &menu.fields[i] }) {
menu.fields[i] = t.chars().filter(|c| !c.is_control()).take(40).collect();
}
}
}
_ => {}
}
}
fn next_seed(seed: u32) -> u32 {
seed.wrapping_mul(1103515245).wrapping_add(12345) | 1
}
@@ -378,21 +433,77 @@ async fn main() {
CloudEvent::Saved(name) => app.set_status(i18n::trf("save.saved", &[name]), now),
CloudEvent::Loaded(bytes) => app.load_bytes(&bytes, &mut ui, &mut menu, &mut r3d, now),
CloudEvent::Deleted => app.set_status(i18n::tr("cloud.deleted"), now),
CloudEvent::Error(key) => app.set_status(i18n::tr(key), now),
CloudEvent::Error(key) => {
if matches!(menu.page, Some(Page::Account)) {
menu.error = Some(i18n::tr(key));
} else {
app.set_status(i18n::tr(key), now);
}
}
// accounts: the login page comes before the title screen
CloudEvent::LoginNeeded => {
if matches!(menu.page, Some(Page::Title) | None) {
menu.open_login();
menu.error = None;
}
}
CloudEvent::LoggedIn => {
if menu.page == Some(Page::Login) {
menu.page = Some(Page::Title);
}
menu.error = None;
if let Some(s) = &cloud.session {
app.set_status(i18n::trf("login.welcome", &[s.name.clone()]), now);
}
}
CloudEvent::LoginFailed(key) => menu.error = Some(i18n::tr(key)),
CloudEvent::PasswordChanged => {
menu.fields[0].clear();
menu.fields[1].clear();
menu.error = None;
app.set_status(i18n::tr("account.pw_changed"), now);
}
CloudEvent::UsersChanged => {
menu.fields[2].clear();
menu.fields[3].clear();
menu.error = None;
app.set_status(i18n::tr("account.users_changed"), now);
}
}
// download of the standalone file
match platform::fetch_download_state() {
2 => app.set_status(i18n::tr("standalone.done"), now),
3 => app.set_status(i18n::tr("standalone.failed"), now),
_ => {}
}
// ---------------- name field of the savegame page
// ---------------- text fields (savegame name, login, account)
if menu.typing() {
// Tab switches the field right away (the following characters go to the next one)
let mut tab_char = false;
while let Some(c) = get_char_pressed() {
menu.type_char(c);
if c == '\t' {
tab_char = true;
menu.next_field();
} else {
menu.type_char(c);
}
}
if is_key_pressed(KeyCode::Tab) && !tab_char {
menu.next_field();
}
if is_key_pressed(KeyCode::Backspace) {
menu.save_name.pop();
menu.backspace();
}
if is_key_pressed(KeyCode::Enter) || is_key_pressed(KeyCode::KpEnter) {
let name = menu.save_name.trim().to_string();
if cloud.save(None, &name, &app.autosave_json()) {
app.set_status(i18n::tr("saves.wait"), now);
if matches!(menu.page, Some(Page::Saves { .. })) {
let name = menu.save_name.trim().to_string();
if cloud.save(None, &name, &app.autosave_json()) {
app.set_status(i18n::tr("saves.wait"), now);
}
} else {
let a = menu.submit();
account_action(a, &mut cloud, &mut menu, &mut app, now);
}
}
}
@@ -680,6 +791,14 @@ async fn main() {
autosave_t = 0.0;
}
MenuAction::OpenRepo => platform::open_url(menu::REPO_URL),
a @ (MenuAction::Login(..)
| MenuAction::Logout
| MenuAction::OpenAccount
| MenuAction::ChangePassword(..)
| MenuAction::CreateUser(..)
| MenuAction::DeleteUser(..)
| MenuAction::DownloadStandalone
| MenuAction::EditField(..)) => account_action(a, &mut cloud, &mut menu, &mut app, now),
MenuAction::EditName => {
if let Some(n) = platform::prompt(&i18n::tr("saves.name"), &menu.save_name) {
menu.save_name = n.chars().filter(|c| !c.is_control()).take(40).collect();
@@ -865,7 +984,13 @@ async fn main() {
let on_title = menu.page == Some(Page::Title)
|| matches!(
menu.page,
Some(Page::Settings { from_title: true, .. }) | Some(Page::KeyCapture { from_title: true, .. }) | Some(Page::NewGame) | Some(Page::Campaign) | Some(Page::Briefing { ingame: false, .. })
Some(Page::Settings { from_title: true, .. })
| Some(Page::KeyCapture { from_title: true, .. })
| Some(Page::NewGame)
| Some(Page::Campaign)
| Some(Page::Briefing { ingame: false, .. })
| Some(Page::Login)
| Some(Page::Account)
);
music.update(&app.game, on_title, (app.cam.tx, app.cam.tz));
for m in app.game.msgs.drain(..) {
@@ -914,7 +1039,13 @@ fn draw_frame(app: &mut App, gfx: &Gfx, r3d: &mut R3d, ui: &mut Ui, menu: &mut M
r3d.draw(&app.game, gfx, &app.cam, now, &ov);
let title_screen = matches!(
menu.page,
Some(Page::Title) | Some(Page::Settings { from_title: true, .. }) | Some(Page::KeyCapture { from_title: true, .. }) | Some(Page::Campaign) | Some(Page::Briefing { ingame: false, .. })
Some(Page::Title)
| Some(Page::Settings { from_title: true, .. })
| Some(Page::KeyCapture { from_title: true, .. })
| Some(Page::Campaign)
| Some(Page::Briefing { ingame: false, .. })
| Some(Page::Login)
| Some(Page::Account)
);
if !title_screen {
let info = if over_ui || (touch && inp.inspect.is_none()) {