Files
benilla-octo/crates/benilla-app/src/login/mod.rs
T
Sam SungandClaude Sonnet 5.5 2af917044d Stock login fetches the server alert; LaunchURL behind the gate (#1040)
The stock login's Breaking News box (`WOW_GLUE_STOCK=1`):

- At glue init, as `0x46a400` does, the client reads the Lua global `SERVER_ALERT_URL`, which the player's own GlueStrings or a server's patch sets. It fetches the URL once on a background thread: plain HTTP, 5 s timeouts, redirects followed.
- The body builds the reference's buffer (`0x46a580`: 0x7ff cap, appended chunks). A case-insensitive `SERVERALERT:` prefix fires `SHOW_SERVER_ALERT` once from the update (`0x46b939`). Every failure is silent.
- `LaunchURL` opens the browser only for URLs that pass the reference's gate (`0x5abc10`): `http://`, a strict charset and the 10-host whitelist. An over-long host is refused rather than cut.
- No fetch or browser ever runs in a test, capture or unattended run.

Tests run against a local fake server: prefix, 404, redirects, chunked, timeouts, caps, and the frame shown end to end.

Part of #1026

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 21:42:29 +07:00

2073 lines
86 KiB
Rust

//! The login screen, the reference's `AccountLogin` glue: state, the credential policy, input,
//! dialogs and the saved account; the layout is [`screen`]. Deviation: the Credits, Cinematics
//! and TOS side of the reference screen is absent, because the screen keeps only what logging in
//! needs.
//!
//! The credential policy drives the IO thread's pre-logon park: `WOW_USER` plus `WOW_PASS`
//! auto-submit, pending credentials resubmit every 3 s (app-side; the IO thread never sleeps),
//! and a typed submit. A refusal clears the credentials and shows its dialog, never a retry.
//!
//! A lost session is over, as in `GlueParent.lua`: `DISCONNECTED_FROM_SERVER` returns to login
//! with the `DISCONNECTED` dialog. Only an unattended run ([`crate::run_mode::unattended`])
//! reconnects, because a client that re-authenticates takes the account back from whoever
//! displaced it.
pub(crate) mod queue;
mod screen;
mod smoke;
use std::sync::atomic::Ordering;
use benilla_ui::widget::EditBoxState;
use bevy::input::keyboard::KeyboardInput;
use crate::textinput::{self, HostClipboard};
use bevy::input::ButtonState;
use bevy::prelude::*;
use benilla_protocol::{DialFailure, LoginRefusal, LoginStage};
use crate::char_select::ClientState;
use crate::glue::dialog::{DialogKind, GlueDialog};
use crate::glue_strings::GlueStrings;
use crate::net::{
CharListMessage, DisconnectedMessage, LoginAbandon, LoginFailedMessage, LoginQueuedMessage,
LoginRequest, LoginStageMessage, LoginSubmit, WorldAuthMessage,
};
use crate::portrait::{GluePreview, GlueScene};
use crate::sound::GlueSound;
pub(crate) use screen::LoginAction;
pub(crate) use smoke::smoke_character;
/// The resubmit delay after a transport failure with pending credentials.
const RETRY_DELAY_SECS: f32 = 3.0;
/// How long `gsTitleQuit` plays before `AppExit` drops the mixer.
const QUIT_GRACE_SECS: f32 = 0.4;
/// `letters="16"` on both edit boxes (`AccountLogin.xml`).
const MAX_LETTERS: usize = 16;
pub(crate) struct LoginPlugin;
impl Plugin for LoginPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<LoginIntent>()
.init_resource::<LoginForm>()
.add_message::<GlueLogin>()
.add_message::<crate::ui_script::server_alert::LaunchUrl>()
.add_systems(
OnEnter(ClientState::Login),
(
enter_login,
// Under the stock glue screens their own feeder drives the booth.
show_main_menu.run_if(crate::ui_script::glue::native_glue),
),
)
.add_systems(
OnExit(ClientState::Login),
(
screen::exit_login,
screen::clear_login_scene.run_if(crate::ui_script::glue::native_glue),
),
)
.add_systems(
Update,
(
// Every state: the reconnect resubmit fires while `InWorld`, and the roster
// edge lands wherever it lands.
// The stock glue screens' manager first, after the tick whose Lua called its
// verbs, so its asks reach the policy in the same frame.
(
crate::ui_script::glue_mgr::drive_glue_mgr
.after(crate::ui_script::UiInput)
.after(crate::realm_select::RealmSelectSet)
.run_if(crate::ui_script::glue::stock_glue),
// Off the roster and the screen the manager and the tick just moved.
crate::ui_script::glue_scene::feed_stock_glue_preview
.run_if(crate::ui_script::glue::stock_glue)
.in_set(crate::portrait::GluePreviewFeed),
crate::ui_script::server_alert::open_launched
// Reads only its message, so no state orders it but the sets.
.after(benilla_world::schedule::WorldStage::Stream)
.run_if(crate::ui_script::glue::stock_glue),
answer_glue,
drive_policy,
to_select_on_roster,
drive_quit,
)
.chain(),
(
screen::materialize_screen,
login_input.run_if(crate::ui_script::glue::native_glue),
tick_login_caret,
screen::refresh_boxes,
screen::refresh_checkbox,
// The shared glue dialog stays inside this chain: ordering it from outside
// would order a shared painter against its own `SystemTypeSet`, which
// panics at schedule build, at boot, where no unit test sees it.
crate::glue::dialog::drive_glue_dialog
.run_if(crate::ui_script::glue::native_glue),
answer_dialog,
// After the driver: it spawns the dialog's edit box, and a realmlist Okay
// changes the address repainted here.
(
crate::glue::dialog::refresh_dialog_box,
screen::refresh_realmlist,
),
)
.chain()
.before(crate::glue::GlueVisuals)
// `answer_dialog` holds the VM, and every VM holder in `Update` declares
// its side of the UI tick; a glue screen has nothing the tick must see.
.after(crate::ui_script::UiInput)
.run_if(in_state(ClientState::Login)),
(smoke::debug_login_smoke, screen::debug_login_shot),
)
.chain()
.after(benilla_world::schedule::WorldStage::Net),
);
}
}
// ── The credential policy ────────────────────────────────────────────────────────────────────────
/// Where the IO thread's read loop is, as far as the app can tell; credentials are submitted
/// only while it is parked pre-logon.
#[derive(Default, PartialEq, Eq, Clone, Copy)]
enum IoPark {
/// Parked pre-logon (boot, a failure, a disconnect, a Back).
#[default]
AtLogin,
/// Past logon: at select or in the world.
Active,
}
/// The credential policy's memory: the session's credentials, the in-flight and park state, and
/// the resubmit timer.
#[derive(Resource, Default)]
pub(crate) struct LoginIntent {
/// Kept in-world so an unattended reconnect re-authenticates silently; cleared by select's
/// Back, a refusal, a Cancel and a lost session.
creds: Option<(String, String)>,
/// Between our send and its LoginFailed or CharacterList answer.
in_flight: bool,
/// The in-flight submit came from the screen, which shows its connecting dialog and failure.
announced: bool,
park: IoPark,
/// `Time::elapsed_secs` deadline for the next silent resubmit.
retry_at: Option<f32>,
/// The env fast path is read once, on the first policy run.
env_read: bool,
}
impl LoginIntent {
/// Forget the session's credentials and any scheduled retry.
pub(crate) fn clear(&mut self) {
self.creds = None;
self.retry_at = None;
}
/// The account this session authenticated as, from the env fast path or the screen; it
/// decides whether the probe shield applies.
pub(crate) fn account(&self) -> Option<&str> {
self.creds.as_ref().map(|(user, _)| user.as_str())
}
}
/// One login attempt's inputs as a single [`SystemParam`]: the policy's memory, the channel to
/// the parked IO thread, the abandon generation a Cancel bumps, and the realmlist it dials. A
/// bundle keeps [`login_input`] under Bevy's sixteen-parameter ceiling.
#[derive(bevy::ecs::system::SystemParam)]
pub(super) struct Attempt<'w> {
pub(super) intent: ResMut<'w, LoginIntent>,
submit: Res<'w, LoginSubmit>,
abandon: Res<'w, LoginAbandon>,
realmlist: Res<'w, crate::realmlist::Realmlist>,
}
impl Attempt<'_> {
/// Send one login attempt to the parked IO thread, stamped with the abandon generation. The
/// realmlist is read at submit time, so an attempt keeps the server it started with.
fn send(&mut self, user: &str, pass: &str, announced: bool) {
self.intent.creds = Some((user.to_string(), pass.to_string()));
self.intent.in_flight = true;
self.intent.announced = announced;
self.intent.retry_at = None;
let _ = self.submit.0.send(LoginRequest {
user: user.to_string(),
pass: pass.to_string(),
host: self.realmlist.address().to_string(),
generation: self.abandon.0.load(Ordering::SeqCst),
});
}
}
/// What the glue screens ask of the login engine: the stock ones past the glue manager's own
/// guards ([`crate::ui_script::glue_mgr`]), and the native realm list's Cancel over login.
#[derive(Message)]
pub(crate) enum GlueLogin {
/// `CGlueMgr::Login` (`0x46afb0`) reaching `ClientServices`' logon (`0x5ab4b0`): one attempt,
/// typed, with the screen's credentials. It supersedes a logon still parked at the realm list,
/// as the reference starts the next logon whatever link the last one left.
Submit { account: String, password: String },
/// `CLoginMgr::Cancel` (`0x5b3320`): the status dialog's Cancel during the logon, and
/// `RealmListDialogCancelled` on the login screen. The attempt in flight is dropped, the
/// realmd link closes, and nothing resubmits.
Abandon,
/// The status dialog's Cancel during the world connect, which completes it
/// `RESPONSE_CANCELLED` (`0x46b754`); the failure arm then drops the world socket alone
/// (`0x46befd`, `0x5ab1a0`), so the attempt in flight ends and the realmd link stays up.
CancelWorld,
/// `DisconnectFromServer` (`0x46d340`): the world session dropped if there is one, then the
/// logon cancelled, as select's Back drops the parked session.
Disconnect,
/// `QuitGame` (`0x46cea0` → `0x401560(0)`): the process ends, with no grace for a sound.
Quit,
}
/// The glue manager's asks: a submit as the screen's own Login press makes it (the account saving
/// is the stock Lua's `SetSavedAccountName`), a cancel as the native Cancel, a disconnect as
/// select's Back, a quit as the native quit with no grace. The screen moves and the quit go
/// through `Commands`, applied before [`drive_quit`] in this chain.
pub(crate) fn answer_glue(
mut asks: MessageReader<GlueLogin>,
mut attempt: Attempt,
pick: Option<Res<crate::net::CharPick>>,
realm: Option<Res<crate::net::RealmChoice>>,
state: Res<State<ClientState>>,
time: Res<Time>,
mut commands: Commands,
) {
for ask in asks.read() {
match ask {
GlueLogin::Submit { account, password } => attempt.send(account, password, true),
GlueLogin::Abandon => {
close_logon(&attempt.abandon, &mut attempt.intent, realm.as_deref());
}
GlueLogin::CancelWorld => {
attempt.abandon.0.fetch_add(1, Ordering::SeqCst);
}
GlueLogin::Disconnect => {
close_logon(&attempt.abandon, &mut attempt.intent, realm.as_deref());
if matches!(
state.get(),
ClientState::CharSelect | ClientState::CharCreate
) {
if let Some(pick) = pick.as_deref() {
let _ = pick.0.send(crate::net::CharRequest::Abandon);
}
commands.set_state(ClientState::Login);
}
}
GlueLogin::Quit => {
info!("login: QuitGame");
commands.insert_resource(QuitArm(Some(time.elapsed_secs())));
}
}
}
}
/// `CLoginMgr::Cancel` (`0x5b3320`): the attempt in flight is dropped at its next stage boundary
/// (a dial asked for under the old generation included), the realmd link closes, and nothing
/// resubmits.
fn close_logon(
abandon: &LoginAbandon,
intent: &mut LoginIntent,
realm: Option<&crate::net::RealmChoice>,
) {
abandon.0.fetch_add(1, Ordering::SeqCst);
intent.in_flight = false;
intent.clear();
if let Some(realm) = realm {
let _ = realm.0.send(crate::net::RealmRequest::Close);
}
}
/// The queue ring's clock, standing in for the reference's `GetTickCount`: only differences
/// matter, and `u32` keeps the reference's wrapping width.
fn queue_now_ms(time: &Time) -> u32 {
time.elapsed().as_millis() as u32
}
/// What a Login press should do: the reference's two empty-field guards, as a verdict.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum LoginPress {
NeedAccount,
NeedPassword,
Submit,
}
/// The account is checked first, so an empty form asks for it, the reference's order.
fn login_press(form: &LoginForm) -> LoginPress {
if form.account.text.is_empty() {
LoginPress::NeedAccount
} else if form.password.text.is_empty() {
LoginPress::NeedPassword
} else {
LoginPress::Submit
}
}
/// The connecting dialog's `LOGIN_STATE_*` text for a stage.
fn stage_text(strings: &GlueStrings, stage: LoginStage) -> &str {
match stage {
LoginStage::Connecting => strings.text("LOGIN_STATE_CONNECTING", "Connecting"),
LoginStage::Authenticating => strings.text("LOGIN_STATE_AUTHENTICATING", "Authenticating"),
// The native dialog keeps the handshake's text through the world socket's connect.
LoginStage::Handshaking | LoginStage::WorldConnected => {
strings.text("LOGIN_STATE_HANDSHAKING", "Handshaking")
}
}
}
/// The failure text for a login result; a transport failure (`None`) reads `LOGIN_FAILED`.
///
/// Deviation: a dial that never opened a socket reads `AUTH_LOGIN_SERVER_NOT_FOUND` or
/// `LOGIN_SERVER_DOWN` plus the address, because with an editable realmlist "Unable to connect"
/// cannot tell a bad name from a server that is down.
fn fail_text(
strings: &GlueStrings,
refusal: Option<LoginRefusal>,
dial: Option<&DialFailure>,
) -> String {
if let Some(dial) = dial {
let headline = if dial.unresolved {
strings.text("AUTH_LOGIN_SERVER_NOT_FOUND", "Invalid Login Server")
} else {
strings.text("LOGIN_SERVER_DOWN", "Login Server Down")
};
return format!("{headline}\n{}", dial.address);
}
let authored = match refusal {
Some(LoginRefusal::World(code)) => world_refusal_text(strings, code),
Some(LoginRefusal::Logon(code) | LoginRefusal::LogonProof(code)) => {
logon_refusal_text(strings, Some(code))
}
None => logon_refusal_text(strings, None),
};
without_dead_url(authored).into_owned()
}
/// The world server's `SMSG_AUTH_RESPONSE` refusal text: the client's dispatch (`0x5aa960`,
/// key table `0x85cae8`), numbered as `AuthResponseCodes` in cmangos `SharedDefines.h:1721+`.
fn world_refusal_text(strings: &GlueStrings, code: u8) -> &str {
use benilla_protocol::messages as m;
let (key, fallback): (&str, &str) = match code {
m::AUTH_FAILED => ("AUTH_FAILED", "Authentication failed"),
m::AUTH_REJECT => ("AUTH_REJECT", "Login unavailable"),
m::AUTH_BAD_SERVER_PROOF => ("AUTH_BAD_SERVER_PROOF", "Server is not valid"),
m::AUTH_UNAVAILABLE => (
"AUTH_UNAVAILABLE",
"System unavailable - Please try again later",
),
m::AUTH_SYSTEM_ERROR => ("AUTH_SYSTEM_ERROR", "System Error"),
m::AUTH_BILLING_ERROR => ("AUTH_BILLING_ERROR", "Billing system error"),
m::AUTH_BILLING_EXPIRED => ("AUTH_BILLING_EXPIRED", "Account billing has expired"),
m::AUTH_VERSION_MISMATCH => ("AUTH_VERSION_MISMATCH", "Wrong client version"),
m::AUTH_UNKNOWN_ACCOUNT => ("AUTH_UNKNOWN_ACCOUNT", "Unknown account"),
m::AUTH_INCORRECT_PASSWORD => ("AUTH_INCORRECT_PASSWORD", "Incorrect Password"),
m::AUTH_SESSION_EXPIRED => ("AUTH_SESSION_EXPIRED", "Session Expired"),
m::AUTH_SERVER_SHUTTING_DOWN => ("AUTH_SERVER_SHUTTING_DOWN", "Server Shutting Down"),
m::AUTH_ALREADY_LOGGING_IN => ("AUTH_ALREADY_LOGGING_IN", "Already Logging In"),
m::AUTH_LOGIN_SERVER_NOT_FOUND => ("AUTH_LOGIN_SERVER_NOT_FOUND", "Invalid Login Server"),
// All but `AUTH_ALREADY_ONLINE` below are the rows of the client's `OKAY_WITH_URL` table
// (`0x803740`). Deviation: no URL button on the native screen, because `LaunchURL`'s
// whitelist (`0x85cc34`) names only Blizzard domains that no longer serve these pages.
m::AUTH_BANNED => (
"AUTH_BANNED",
"This account has been banned for violating the Terms of Use Agreement",
),
m::AUTH_ALREADY_ONLINE => ("AUTH_ALREADY_ONLINE", "This character is still logged on"),
m::AUTH_NO_TIME => (
"AUTH_NO_TIME",
"Your World of Warcraft subscription has expired",
),
m::AUTH_DB_BUSY => ("AUTH_DB_BUSY", "This session has timed out"),
m::AUTH_SUSPENDED => (
"AUTH_SUSPENDED",
"This account has been temporarily suspended for violating the Terms of Use Agreement",
),
m::AUTH_PARENTAL_CONTROL => (
"AUTH_PARENTAL_CONTROL",
"Access to this account has been blocked by parental controls.",
),
// `AUTH_OK` and `AUTH_WAIT_QUEUE` never reach here; anything else is unknown.
_ => ("AUTH_FAILED", "Authentication failed"),
};
strings.text(key, fallback)
}
/// Cut a failure string at the clause (comma or full stop) before its first web address.
/// Deviation: the reference prints the address, but those Blizzard pages no longer serve and a
/// private realm's player is not served by them. With no boundary before the address (as in
/// `AUTH_BANNED`) the string is left whole.
fn without_dead_url(text: &str) -> std::borrow::Cow<'_, str> {
let Some(url) = ["www.", "http://", "https://"]
.iter()
.filter_map(|p| text.find(p))
.min()
else {
return std::borrow::Cow::Borrowed(text);
};
let head = &text[..url];
let Some(cut) = head.rfind([',', '.']) else {
return std::borrow::Cow::Borrowed(text);
};
let kept = head[..cut].trim_end();
if kept.is_empty() {
return std::borrow::Cow::Borrowed(text);
}
std::borrow::Cow::Owned(format!("{kept}."))
}
/// The realmd logon-proof refusal text, the long `LOGIN_*` family: realmd results resolve
/// through key table `0x836b78` (`Logon::OnAuthResult` `0x5b2c90`, byte-index table `0x5b2ea4`,
/// jump table `0x5b2e78`), the world server's through `0x85cae8` ([`world_refusal_text`]).
///
/// 0x04 and 0x05 share one arm, `LOGIN_UNKNOWN_ACCOUNT`: the reference never shows
/// `LOGIN_INCORRECT_PASSWORD` and has no lockout. Codes past 0x0F saturate to the arm that shows
/// `DISCONNECTED`.
fn logon_refusal_text(strings: &GlueStrings, code: Option<u8>) -> &str {
let (key, fallback): (&str, &str) = match code {
// No code, unknown0/1, invalid server and no access all read `LOGIN_FAILED`.
None | Some(0x01) | Some(0x02) | Some(0x0B) | Some(0x0D) => {
("LOGIN_FAILED", "Unable to connect")
}
Some(0x03) => (
"LOGIN_BANNED",
"This World of Warcraft account has been closed and is no longer available for use. \
Please go to http://www.worldofwarcraft.com/misc/banned.html for further information. ",
),
// The unknown account and the wrong password alike.
Some(0x04) | Some(0x05) => (
"LOGIN_UNKNOWN_ACCOUNT",
"The information you have entered is not valid. Please check the spelling of the \
account name and password. If you need help in retrieving a lost or stolen password \
and account, see www.worldofwarcraft.com for more information.",
),
Some(0x06) => (
"LOGIN_ALREADYONLINE",
"This account is already logged into World of Warcraft. Please check the spelling and \
try again.",
),
Some(0x07) => (
"LOGIN_NOTIME",
"You have used up your prepaid time for this account. Please purchase more to continue \
playing",
),
Some(0x08) => (
"LOGIN_DBBUSY",
"Could not log in to World of Warcraft at this time. Please try again later.",
),
Some(0x09) => (
"LOGIN_BADVERSION",
"Unable to validate game version. This may be caused by file corruption or the \
interference of another program. Please visit www.blizzard.com/support/wow/ for more \
information and possible solutions to this issue.",
),
Some(0x0C) => (
"LOGIN_SUSPENDED",
"This World of Warcraft account has been temporarily suspended. Please go to \
http://www.worldofwarcraft.com/misc/banned.html for further information.",
),
Some(0x0F) => (
"LOGIN_PARENTALCONTROL",
"Access to this account has been blocked by parental controls. Your settings may be \
changed in your account preferences at http://www.worldofwarcraft.com.",
),
// 0x0A (version update) is ignored on the proof (`0x5bada2`); every remaining code takes
// the disconnect arm.
Some(_) => ("DISCONNECTED", "Disconnected from server"),
};
strings.text(key, fallback)
}
/// The policy tick and the net-message reactions; the screen's own submit is [`login_input`]'s.
fn drive_policy(
mut attempt: Attempt,
realm_list_up: Res<crate::realm_select::Realms>,
mut dialog: ResMut<GlueDialog>,
strings: Option<Res<GlueStrings>>,
time: Res<Time>,
mut stages: MessageReader<LoginStageMessage>,
mut queued: MessageReader<LoginQueuedMessage>,
mut failures: MessageReader<LoginFailedMessage>,
mut disconnects: MessageReader<DisconnectedMessage>,
mut exit: MessageWriter<AppExit>,
) {
let now = time.elapsed_secs();
// A driverless run exits non-zero ("login: FATAL") on a failure no resubmit can change, as
// [`crate::run_mode::fatal_when_driverless`] decides; the smoke owns its own verdict.
let smoke = std::env::var_os("WOW_LOGIN_SMOKE").is_some();
let empty = GlueStrings::default();
let strings = strings.as_deref().unwrap_or(&empty);
// The env fast path, once: `WOW_USER` and `WOW_PASS` both set auto-submit, except under the
// login smoke, which drives its own credentials. There is no default account.
if !attempt.intent.env_read {
attempt.intent.env_read = true;
if crate::run_mode::env_login() && std::env::var_os("WOW_LOGIN_SMOKE").is_none() {
let user = std::env::var("WOW_USER").unwrap_or_default();
let pass = std::env::var("WOW_PASS").unwrap_or_default();
// A vmangos login kicks whoever holds the account, so the automated path logs in
// only as the checkout's declared account (`.probe-identity`); a typed login is not
// gated.
match crate::run_mode::account_guard(&user) {
Ok(()) => {
info!("login: env fast path — auto-submitting as {user}");
attempt.intent.creds = Some((user, pass));
attempt.intent.retry_at = Some(now);
}
Err(why) if std::env::var_os("WOW_ALLOW_ACCOUNT").is_some() => {
warn!("login: {why} — WOW_ALLOW_ACCOUNT is set, going ahead anyway");
attempt.intent.creds = Some((user, pass));
attempt.intent.retry_at = Some(now);
}
Err(why) => {
error!("login: REFUSING the env fast path — {why} Set WOW_ALLOW_ACCOUNT=1 if the cross-account login is deliberate.");
dialog.open_error(&why);
// The refusal is deterministic, so the run can never get past this screen.
if crate::run_mode::fatal_when_driverless(
"the account guard refused the only credentials this run has",
) {
exit.write(AppExit::error());
}
}
}
}
}
for msg in stages.read() {
if matches!(dialog.kind, Some(DialogKind::Status)) {
dialog.set_text(stage_text(strings, msg.stage));
}
}
// Reaching the realm list moves the login state past `LOGIN_STATE_CONNECTING`, and
// `CGlueMgr::UpdateGlueDialog` (`0x46b140`) then clears the status dialog.
if realm_list_up.shown && matches!(dialog.kind, Some(DialogKind::Status)) {
dialog.close();
}
// Each queue packet is one sample; the first turns the connecting dialog into the queue
// dialog. `in_flight` stays set, so nothing resubmits under a queue.
for msg in queued.read() {
if !matches!(dialog.kind, Some(DialogKind::Queued)) {
dialog.open_queued(msg.realm.clone());
}
if let Some(position) = msg.position {
dialog.queue.sample(position, queue_now_ms(&time));
}
info!(
"login: queued for {} at position {}",
msg.realm.as_deref().unwrap_or("the realm"),
msg.position
.map_or_else(|| "unknown".to_string(), |p| p.to_string()),
);
}
// The estimate moves between packets, so the text is recomputed every frame.
if matches!(dialog.kind, Some(DialogKind::Queued)) {
let text = dialog.queue.text(
queue_now_ms(&time),
dialog.queue_realm.clone().as_deref(),
strings,
);
dialog.set_text(&text);
}
for msg in failures.read() {
attempt.intent.in_flight = false;
attempt.intent.park = IoPark::AtLogin;
// `announced` is set only by the screen's submit, and `LoginIntent::clear` leaves it, so
// it says a person typed this attempt; only an untyped attempt may end the run.
let typed = attempt.intent.announced;
let may_end_the_run = !typed && !smoke;
if msg.terminal {
// No resubmit can change it: show the server's words and drop the credentials.
warn!("login: {}", msg.reason);
attempt.intent.clear();
dialog.open_error(&msg.reason);
if may_end_the_run
&& crate::run_mode::fatal_when_driverless(&format!(
"terminal login failure with nobody at the keyboard ({})",
msg.reason
))
{
exit.write(AppExit::error());
}
continue;
}
match msg.refusal {
Some(refusal) => {
// A refusal from either server: shown even on the silent path, never retried.
let byte = refusal.byte();
warn!("login: refused ({refusal:?}, {byte:#04x}) — {}", msg.reason);
attempt.intent.clear();
dialog.open_error(&fail_text(strings, Some(refusal), None));
if may_end_the_run
&& crate::run_mode::fatal_when_driverless(&format!(
"refused ({refusal:?}, {byte:#04x}) and no resubmit can change it"
))
{
exit.write(AppExit::error());
}
}
None if attempt.intent.announced => {
warn!("login: {}", msg.reason);
dialog.open_error(&fail_text(strings, None, msg.dial.as_ref()));
}
None => {
// A silent transport failure schedules the paced resubmit.
debug!("login: transport failure ({}) — retrying", msg.reason);
if attempt.intent.creds.is_some() {
attempt.intent.retry_at = Some(now + RETRY_DELAY_SECS);
}
}
}
}
for msg in disconnects.read() {
attempt.intent.in_flight = false;
// A logout or a refused character login keeps the world link: the IO thread is back at
// character select on it (`0x5aaeb0`, `0x5aad70`), with nothing to log on again.
if msg.end == benilla_protocol::SessionEnd::LoggedOut {
continue;
}
attempt.intent.park = IoPark::AtLogin;
if msg.session_over {
// `DISCONNECTED_FROM_SERVER`: `GlueParent.lua` shows the login screen and the
// `DISCONNECTED` dialog. The credentials go, so nothing takes the account back.
warn!(
"login: {} — session over, back to the login screen",
msg.reason
);
attempt.intent.clear();
dialog.open_error(strings.text("DISCONNECTED", "Disconnected from server"));
continue;
}
// Otherwise an unattended run's silent re-auth, paced after the stream death.
if attempt.intent.creds.is_some() {
attempt.intent.retry_at = Some(now + RETRY_DELAY_SECS);
}
}
if attempt.intent.park == IoPark::AtLogin
&& !attempt.intent.in_flight
&& attempt.intent.retry_at.is_some_and(|t| now >= t)
{
if let Some((user, pass)) = attempt.intent.creds.clone() {
attempt.send(&user, &pass, false);
} else {
attempt.intent.retry_at = None;
}
}
}
/// The login's success edge: go to CharSelect, but only from `Login`, since an unattended
/// reconnect's roster lands while `InWorld`, and a logout's at character select. The native
/// screens take the roster for it, which the IO thread fetches on reaching the park; the stock
/// screens take the world server's authentication (`SMSG_AUTH_RESPONSE`, `0x5ab2b8`), since the
/// roster there is the screen's own ask and comes only once the screen is up.
fn to_select_on_roster(
mut msgs: MessageReader<CharListMessage>,
mut auths: MessageReader<WorldAuthMessage>,
stock: Option<Res<crate::ui_script::glue::GlueStock>>,
mut intent: ResMut<LoginIntent>,
mut dialog: ResMut<GlueDialog>,
state: Res<State<ClientState>>,
mut next: ResMut<NextState<ClientState>>,
abandon: Option<Res<LoginAbandon>>,
pick: Option<Res<crate::net::CharPick>>,
) {
// A message of an attempt a Cancel or a disconnect has passed since is not an edge: the
// reference drops the socket there (`0x46befd`, `0x46d360`), so the session that parked
// behind the screen's back is abandoned.
let now = LoginAbandon::now(abandon.as_deref());
let (mut edge, mut stale) = (false, false);
let generations: Vec<u64> = if stock.is_some_and(|s| s.0) {
msgs.read().for_each(drop);
auths.read().map(|m| m.generation).collect()
} else {
auths.read().for_each(drop);
msgs.read().map(|m| m.generation).collect()
};
for generation in generations {
if generation == now {
edge = true;
} else {
stale = true;
}
}
if stale && *state.get() == ClientState::Login {
if let Some(pick) = pick.as_deref() {
let _ = pick.0.send(crate::net::CharRequest::Abandon);
}
}
if !edge {
return;
}
intent.in_flight = false;
intent.park = IoPark::Active;
intent.retry_at = None;
// Only the dialogs the roster answers: a refused character login's roster follows it on the
// kept link, and its `Error` is dismissed by its Okay, never by a packet.
if matches!(dialog.kind, Some(DialogKind::Status | DialogKind::Queued)) {
dialog.close();
}
if *state.get() == ClientState::Login {
next.set(ClientState::CharSelect);
}
}
// ── The screen's form state + input ──────────────────────────────────────────────────────────────
/// Which edit box has the focus.
#[derive(Default, Clone, Copy, PartialEq, Eq, Debug)]
pub(super) enum Field {
#[default]
Account,
Password,
}
/// The typed form: both boxes, the focus and the Remember checkbox. Each box is the shared
/// [`EditBoxState`], caret blink (the client's 0.5 s period) included.
#[derive(Resource)]
pub(crate) struct LoginForm {
pub(super) account: EditBoxState,
pub(super) password: EditBoxState,
pub(super) focus: Field,
pub(super) save: bool,
}
impl Default for LoginForm {
fn default() -> Self {
LoginForm {
account: textinput::field(MAX_LETTERS, false),
// Masks the display; the real text is never rendered or copied.
password: textinput::field(MAX_LETTERS, true),
focus: Field::default(),
save: false,
}
}
}
impl LoginForm {
/// Give `field` the keyboard as the stock `AccountLogin.xml` boxes take it: the box left drops
/// its selection (`OnEditFocusLost`, `HighlightText(0, 0)`), and the box gaining it selects
/// all (`OnEditFocusGained`, `HighlightText()`), which leaves its caret where it was
/// (`0x77cca0`). The focus change raises the caret bit (`0x77afa8`), whose flush restarts the
/// blink (`0x77dd68`, `0x77de35`), so the caret opens solid.
fn focus(&mut self, field: Field) {
self.focused().highlight_text(0, 0);
self.focus = field;
let f = self.focused();
f.highlight_text(0, -1);
f.reset_blink();
}
fn focused(&mut self) -> &mut EditBoxState {
match self.focus {
Field::Account => &mut self.account,
Field::Password => &mut self.password,
}
}
}
/// The armed quit: `gsTitleQuit` gets [`QUIT_GRACE_SECS`] before `AppExit`.
#[derive(Resource, Default)]
struct QuitArm(Option<f32>);
/// `AccountLogin_OnShow`: prefill the saved account, clear the password, focus the first empty
/// box, check Remember when a name is saved.
fn enter_login(mut form: ResMut<LoginForm>) {
let saved = load_saved_account();
form.save = !saved.is_empty();
form.focus = if saved.is_empty() {
Field::Account
} else {
Field::Password
};
form.account.set_text(&saved);
form.password.set_text("");
// Focus starts the caret solid (`set_text` no-ops on an unchanged name) and selects, so a
// remembered name is typed over.
let focus = form.focus;
form.focus(focus);
}
/// The native login's scene, `AccountLogin.xml`'s `ModelFFX`: `UI_MainMenu` under its fog.
fn show_main_menu(mut preview: ResMut<GluePreview>) {
preview.scene = Some(GlueScene::MainMenu);
preview.look = None;
preview.yaw = 0.0;
preview.fog = Some(GlueScene::MainMenu.stock_fog());
// `AccountLogin`'s OnShow (`0x46fa60`).
preview.shown = preview.shown.wrapping_add(1);
}
/// The screen's input: typing, Tab, Enter submits, Esc quits (an open dialog takes it first),
/// and clicks on the boxes, buttons and checkbox.
#[allow(clippy::type_complexity)]
fn login_input(
realms: Res<crate::realm_select::Realms>,
presses: Query<(Entity, &LoginAction, Ref<Interaction>)>,
clicks: Res<crate::glue::GlueClicks>,
mut keyboard: MessageReader<KeyboardInput>,
keys: Res<ButtonInput<KeyCode>>,
// The pasteboard, and the layout that names the clipboard chords' letters.
(mut clipboard, layout): (NonSendMut<HostClipboard>, Res<crate::bindings::LayoutNames>),
raw_handle: Query<&bevy::window::RawHandleWrapper, With<bevy::window::PrimaryWindow>>,
mut form: ResMut<LoginForm>,
mut attempt: Attempt,
mut dialog: ResMut<GlueDialog>,
strings: Option<Res<GlueStrings>>,
mut sounds: MessageWriter<GlueSound>,
mut quit: Local<bool>,
mut commands: Commands,
time: Res<Time>,
) {
// The reference's `RealmList` is a DIALOG-strata frame over this screen, so while it is up
// everything here is inert, Escape included (it is the list's Cancel), through the frame
// its own Escape closes it in.
if realms.owns_input() {
return;
}
let empty = GlueStrings::default();
let strings = strings.as_deref().unwrap_or(&empty);
let mut do_login = false;
let mut do_quit = false;
let dialog_open = dialog.kind.is_some();
for (entity, action, interaction) in &presses {
if dialog_open {
continue;
}
// The edit boxes focus on the press: `CEditBox`'s OnMouseDown (`0x77b800`) takes focus
// unconditionally, without waiting for a release.
if interaction.is_changed() && *interaction == Interaction::Pressed {
match action {
LoginAction::FocusAccount => form.focus(Field::Account),
LoginAction::FocusPassword => form.focus(Field::Password),
_ => {}
}
}
// Everything else here is a Button, which fires on the release.
if !clicks.hit(entity) {
continue;
}
match action {
LoginAction::FocusAccount | LoginAction::FocusPassword => {} // focused on the press
LoginAction::Login => do_login = true,
LoginAction::Quit => do_quit = true,
LoginAction::Realmlist => {
// `gsLoginNewAccount`, which the reference plays for this corner's other buttons
// (`AccountLogin_ManageAccount`, `AccountLogin_LaunchCommunitySite`).
sounds.write(GlueSound("gsLoginNewAccount"));
if attempt.realmlist.pinned_by_env() {
// `$WOW_HOST` owns the address for the session; say so rather than open an
// editor whose Okay would do nothing.
dialog.open_error(&format!(
"$WOW_HOST is set for this session, so the realmlist is fixed at {}.",
attempt.realmlist.address(),
));
} else {
dialog.open_realmlist(
// The reference's registered help text for the `realmList` CVar.
"Address of realm list server",
attempt.realmlist.address(),
);
}
}
LoginAction::ToggleSave => {
form.save = !form.save;
// As `AccountLoginSaveAccountName`'s OnClick: checked plays the "Off" kit,
// unchecked the "On" kit.
sounds.write(GlueSound(if form.save {
"igMainMenuOptionCheckBoxOff"
} else {
"igMainMenuOptionCheckBoxOn"
}));
if !form.save {
save_account("");
}
}
}
}
let mods = textinput::mods_now(&keys);
let wl = textinput::wayland_display(raw_handle.iter().next());
for ev in keyboard.read() {
// An open dialog owns the keyboard (`GlueDialog` is `toplevel`, `enableKeyboard`);
// Enter and Escape are read by [`crate::glue::dialog::drive_glue_dialog`] as its buttons.
if dialog_open {
if dialog.kind.is_some_and(DialogKind::has_edit_box) {
textinput::feed_key(
&mut dialog.edit,
ev,
mods,
&layout,
&mut clipboard,
wl,
textinput::CharFilter::Any,
);
}
continue;
}
// The shared edit box first; what it leaves unclaimed is the screen's (Tab here, Enter
// and Escape below).
if textinput::feed_key(
form.focused(),
ev,
mods,
&layout,
&mut clipboard,
wl,
textinput::CharFilter::Any,
) == textinput::FieldKey::Consumed
{
if form.focus == Field::Account {
on_account_edited(&mut form);
}
continue;
}
if ev.state == ButtonState::Pressed && ev.key_code == KeyCode::Tab {
let next = match form.focus {
Field::Account => Field::Password,
Field::Password => Field::Account,
};
form.focus(next);
}
}
if !dialog_open
&& (keys.just_pressed(KeyCode::Enter) || keys.just_pressed(KeyCode::NumpadEnter))
{
do_login = true;
}
if !dialog_open && keys.just_pressed(KeyCode::Escape) {
do_quit = true;
}
if do_login && !attempt.intent.in_flight {
// `gsLogin` on every press: `AccountLogin_Login` plays it before `DefaultServerLogin`,
// so an empty-box refusal clicks too.
sounds.write(GlueSound("gsLogin"));
match login_press(&form) {
// The reference's empty-box guards: a dialog, nothing on the wire.
LoginPress::NeedAccount => {
dialog.open_error(
strings.text("LOGIN_ENTER_NAME", "Please enter your account name."),
);
}
LoginPress::NeedPassword => {
dialog.open_error(
strings.text("LOGIN_ENTER_PASSWORD", "Please enter your password."),
);
}
LoginPress::Submit => {
// `AccountLogin_Login`: save or clear the name per the checkbox, then clear the
// password box.
if form.save {
save_account(&form.account.text);
} else {
save_account("");
}
let (user, pass) = (form.account.text.clone(), form.password.text.clone());
form.password.set_text("");
dialog.open_status(strings.text("LOGIN_STATE_CONNECTING", "Connecting"));
attempt.send(&user, &pass, true);
}
}
}
if do_quit && !*quit {
*quit = true;
sounds.write(GlueSound("gsTitleQuit"));
commands.insert_resource(QuitArm(Some(time.elapsed_secs() + QUIT_GRACE_SECS)));
}
}
/// The focused box's caret clock, on the shared 0.5 s period. A dialog with an edit box takes
/// the blink, so only one caret is live; any other dialog leaves the form's caret running.
fn tick_login_caret(mut form: ResMut<LoginForm>, mut dialog: ResMut<GlueDialog>, time: Res<Time>) {
let dt = time.delta_secs();
let in_dialog = dialog.kind.is_some_and(DialogKind::has_edit_box);
textinput::tick_caret(form.focused(), !in_dialog, dt);
if in_dialog {
textinput::tick_caret(&mut dialog.edit, true, dt);
}
}
/// Editing the account box away from the saved name clears the save and unchecks
/// (`OnTextChanged`).
fn on_account_edited(form: &mut LoginForm) {
if form.save {
let saved = load_saved_account();
if !saved.is_empty() && saved != form.account.text {
save_account("");
form.save = false;
}
}
}
/// Fire the armed quit once its grace has elapsed.
fn drive_quit(arm: Option<Res<QuitArm>>, time: Res<Time>, mut exit: MessageWriter<AppExit>) {
if let Some(arm) = arm {
if arm.0.is_some_and(|t| time.elapsed_secs() >= t) {
exit.write(AppExit::Success);
}
}
}
// ── The GlueDialog (connecting / error) ──────────────────────────────────────────────────────────
/// The realmlist dialog's prompt for a value that is not an address; the typed text stays for
/// editing.
pub(crate) const REALMLIST_BAD: &str =
"That is not a server address.\nTry logon.example.org or 127.0.0.1:3724";
/// What a glue-dialog press means on this screen; the widget
/// ([`crate::glue::dialog::drive_glue_dialog`]) owns the tree, keys and sound, and ends an `Error`.
fn answer_dialog(
mut commands: Commands,
mut answers: MessageReader<crate::glue::dialog::GlueDialogAnswer>,
mut dialog: ResMut<GlueDialog>,
mut intent: ResMut<LoginIntent>,
mut realmlist: ResMut<crate::realmlist::Realmlist>,
mut script: Option<NonSendMut<benilla_ui::script::UiScript>>,
abandon: Res<LoginAbandon>,
realm: Option<Res<crate::net::RealmChoice>>,
) {
for answer in answers.read() {
match answer.kind {
// Already dismissed by the widget.
DialogKind::Error => {}
DialogKind::Realmlist => {
// Okay takes the address or stays open with the text as typed, the one press
// that does not end its dialog. Cancel (or Escape) closes.
if answer.button1 {
let typed = dialog.edit.text.clone();
if accept_realmlist(&typed, &mut realmlist, script.as_deref_mut()) {
dialog.dismiss(&mut commands);
} else {
dialog.set_text(REALMLIST_BAD);
}
} else if answer.button2 {
dialog.dismiss(&mut commands);
}
}
// Cancel, as the reference's state-1 Cancel (`0x46b73d` → `CLoginMgr::Cancel`).
DialogKind::Status | DialogKind::Queued => {
close_logon(&abandon, &mut intent, realm.as_deref());
dialog.dismiss(&mut commands);
}
}
}
}
/// Take the realmlist dialog's Okay: normalize, point the session at it, and write the
/// `realmList` CVar so it persists; `false` keeps the dialog open. A CVar the VM has not
/// registered yet is not saved, but the session value stands.
fn accept_realmlist(
typed: &str,
realmlist: &mut crate::realmlist::Realmlist,
script: Option<&mut benilla_ui::script::UiScript>,
) -> bool {
let Some(address) = crate::realmlist::normalize(typed) else {
return false;
};
realmlist.set(&address);
let name = crate::realmlist::CVAR_REALMLIST;
match script {
Some(script) => {
script.set_cvar_engine(name, &address);
if script.cvar(name).is_some() {
info!("login: realmlist -> {address}");
} else {
warn!(
"login: realmlist -> {address} for this session, but the VM has not registered \
{name} yet, so it was not saved"
);
}
}
None => {
warn!("login: realmlist -> {address} for this session only — no UI VM to persist it")
}
}
true
}
// ── The saved account name ────────────────────────────────────────────────────
/// Read the saved account name at `path`; a missing file reads empty.
fn load_saved_account_from(path: &std::path::Path) -> String {
std::fs::read_to_string(path)
.map(|s| s.trim().to_string())
.unwrap_or_default()
}
/// Write (or, for an empty name, remove) the saved account name at `path`.
fn save_account_to(path: &std::path::Path, name: &str) {
if name.is_empty() {
let _ = std::fs::remove_file(path);
return;
}
if let Some(dir) = path.parent() {
if std::fs::create_dir_all(dir).is_err() {
return;
}
}
if let Err(e) = std::fs::write(path, name) {
warn!("login: saving account name failed: {e}");
}
}
/// `GetSavedAccountName`, at [`crate::local_state`]'s path.
pub(crate) fn load_saved_account() -> String {
crate::local_state::saved_account_path()
.map(|p| load_saved_account_from(&p))
.unwrap_or_default()
}
/// `SetSavedAccountName`; empty clears.
pub(crate) fn save_account(name: &str) {
if let Some(path) = crate::local_state::saved_account_path() {
save_account_to(&path, name);
}
}
#[cfg(test)]
mod tests {
use super::*;
/// `drive_policy` alone with the env fast path spent, so an ambient `WOW_USER` cannot seed
/// credentials.
fn policy_app() -> (App, crossbeam_channel::Receiver<LoginRequest>) {
let (tx, rx) = crossbeam_channel::unbounded();
let mut app = App::new();
app.init_resource::<Time>()
.init_resource::<LoginIntent>()
// `GluePlugin`'s resource, seated here without the plugin.
.init_resource::<GlueDialog>()
.init_resource::<crate::realm_select::Realms>()
// Not `Realmlist::default()`, which reads `$WOW_HOST` from the shell.
.insert_resource(crate::realmlist::Realmlist::unpinned(
crate::realmlist::DEFAULT_REALMLIST,
))
.insert_resource(LoginSubmit(tx))
.insert_resource(LoginAbandon(std::sync::Arc::new(
std::sync::atomic::AtomicU64::new(0),
)))
.add_message::<LoginStageMessage>()
.add_message::<LoginQueuedMessage>()
.add_message::<LoginFailedMessage>()
.add_message::<DisconnectedMessage>()
.add_systems(Update, drive_policy);
app.world_mut().resource_mut::<LoginIntent>().env_read = true;
// Returned, not dropped: a dropped receiver turns every submit into an `Err`.
(app, rx)
}
/// Escape on the realm list over this screen closes the list and nothing else: the press is
/// the list's (`Realms::owns_input`), so the login screen does not also quit, in either
/// order the two run.
#[test]
fn escape_on_the_realm_list_does_not_also_quit() {
for list_first in [true, false] {
let (tx, _requests) = crossbeam_channel::unbounded();
let (realm_tx, realm_rx) = crossbeam_channel::unbounded();
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.init_resource::<crate::glue::GlueClicks>()
.init_resource::<ButtonInput<KeyCode>>()
.init_resource::<crate::bindings::LayoutNames>()
.insert_non_send_resource(HostClipboard::default())
.init_resource::<LoginForm>()
.init_resource::<LoginIntent>()
.init_resource::<GlueDialog>()
.init_resource::<crate::realm_select::Realms>()
.insert_resource(crate::realmlist::Realmlist::unpinned(
crate::realmlist::DEFAULT_REALMLIST,
))
.insert_resource(LoginSubmit(tx))
.insert_resource(LoginAbandon(std::sync::Arc::new(
std::sync::atomic::AtomicU64::new(0),
)))
.insert_resource(crate::net::RealmChoice(realm_tx.into()))
.add_message::<KeyboardInput>()
.add_message::<GlueSound>()
.add_message::<GlueLogin>()
.add_message::<bevy::input::mouse::MouseWheel>()
.add_systems(PreUpdate, crate::realm_select::take_input);
let list = crate::realm_select::list_keys;
if list_first {
app.add_systems(Update, (list, login_input).chain());
} else {
app.add_systems(Update, (login_input, list).chain());
}
// The list stands over this screen, as the realm park raises it after logon.
app.world_mut()
.resource_mut::<crate::realm_select::Realms>()
.shown = true;
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.press(KeyCode::Escape);
app.update();
let order = if list_first {
"list first"
} else {
"screen first"
};
assert!(
!app.world().resource::<crate::realm_select::Realms>().shown,
"{order}: the list's Cancel closed it"
);
let asks = app
.world()
.resource::<bevy::ecs::message::Messages<GlueLogin>>();
assert!(
asks.iter_current_update_messages()
.any(|a| matches!(a, GlueLogin::Abandon)),
"{order}: and ended the attempt, which closes the logon"
);
assert!(
realm_rx.try_recv().is_err(),
"{order}: the login engine sends the close"
);
assert!(
!app.world().contains_resource::<QuitArm>(),
"{order}: the press was the list's, so the login screen does not also quit"
);
}
}
/// The status dialog's Cancel is `CLoginMgr::Cancel` (`0x46b73d` → `0x5b3320`): the attempt
/// ends at its next boundary and the logon park closes, so a logon that already finished is
/// not left parked.
#[test]
fn the_status_cancel_closes_the_logon() {
let (realm_tx, realm_rx) = crossbeam_channel::unbounded();
let abandon = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
let mut app = App::new();
app.init_resource::<GlueDialog>()
.init_resource::<LoginIntent>()
.insert_resource(crate::realmlist::Realmlist::unpinned(
crate::realmlist::DEFAULT_REALMLIST,
))
.insert_resource(LoginAbandon(abandon.clone()))
.insert_resource(crate::net::RealmChoice(realm_tx.into()))
.add_message::<crate::glue::dialog::GlueDialogAnswer>()
.add_systems(Update, answer_dialog);
app.world_mut().resource_mut::<LoginIntent>().in_flight = true;
app.world_mut()
.write_message(crate::glue::dialog::GlueDialogAnswer {
kind: DialogKind::Status,
button1: true,
button2: false,
});
app.update();
assert_eq!(abandon.load(Ordering::SeqCst), 1);
assert!(matches!(
realm_rx.try_recv(),
Ok(crate::net::RealmRequest::Close)
));
assert!(!app.world().resource::<LoginIntent>().in_flight);
}
/// The realm list's Cancel over the login screen ends the attempt (#833): the logon closes
/// and the next Login is sent, where the attempt left in flight refused it.
#[test]
fn the_realm_lists_cancel_lets_the_next_login_through() {
let (tx, requests) = crossbeam_channel::unbounded();
let (realm_tx, realm_rx) = crossbeam_channel::unbounded();
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.init_resource::<crate::glue::GlueClicks>()
.init_resource::<ButtonInput<KeyCode>>()
.init_resource::<crate::bindings::LayoutNames>()
.insert_non_send_resource(HostClipboard::default())
.init_resource::<LoginForm>()
.init_resource::<LoginIntent>()
.init_resource::<GlueDialog>()
.init_resource::<crate::realm_select::Realms>()
.insert_resource(crate::realmlist::Realmlist::unpinned(
crate::realmlist::DEFAULT_REALMLIST,
))
.insert_resource(LoginSubmit(tx))
.insert_resource(LoginAbandon(std::sync::Arc::new(
std::sync::atomic::AtomicU64::new(0),
)))
.insert_resource(crate::net::RealmChoice(realm_tx.into()))
.insert_resource(State::new(ClientState::Login))
.add_message::<KeyboardInput>()
.add_message::<GlueSound>()
.add_message::<GlueLogin>()
.add_message::<bevy::input::mouse::MouseWheel>()
.add_systems(PreUpdate, crate::realm_select::take_input)
.add_systems(
Update,
(crate::realm_select::list_keys, answer_glue, login_input).chain(),
);
{
let w = app.world_mut();
let mut form = w.resource_mut::<LoginForm>();
form.account.text = "probe".into();
form.password.text = "secret".into();
// The attempt reached the realm park, which raised the list over this screen.
w.resource_mut::<LoginIntent>().in_flight = true;
w.resource_mut::<crate::realm_select::Realms>().shown = true;
}
let press = |app: &mut App, key: KeyCode| {
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.press(key);
app.update();
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.clear();
};
press(&mut app, KeyCode::Escape);
assert!(
matches!(realm_rx.try_recv(), Ok(crate::net::RealmRequest::Close)),
"Cancel closes the logon"
);
assert!(!app.world().resource::<LoginIntent>().in_flight);
app.update(); // the list's input ownership settles
press(&mut app, KeyCode::Enter);
let req = requests.try_recv().expect("the next Login is sent");
assert_eq!(req.user, "probe");
}
/// A lost session does not log itself back in: a displaced client's resubmit would kick the
/// client that displaced it. `GlueParent.lua` retries nothing.
#[test]
fn a_lost_session_clears_the_credentials_and_shows_the_dialog() {
let (mut app, _requests) = policy_app();
app.world_mut().resource_mut::<LoginIntent>().creds =
Some(("player".into(), "secret".into()));
app.world_mut().write_message(DisconnectedMessage {
reason: "disconnected: world stream closed: failed to fill whole buffer".into(),
end: benilla_protocol::SessionEnd::Lost,
session_over: true,
});
app.update();
let intent = app.world().resource::<LoginIntent>();
assert!(
intent.creds.is_none(),
"the session's credentials die with the session — keeping them is what won the \
account back off the client that displaced us",
);
assert!(intent.retry_at.is_none(), "and nothing is scheduled");
let dialog = app.world().resource::<GlueDialog>();
assert_eq!(dialog.kind, Some(DialogKind::Error));
// The fallback, the same words as `GlueStrings.lua`'s `DISCONNECTED`.
assert_eq!(dialog.text, "Disconnected from server");
}
/// A logout rides the same message but keeps the world link, so nothing logs on again: the
/// IO thread is at character select on that link, and the roster comes on it.
#[test]
fn a_logout_teardown_logs_on_no_more() {
let (mut app, requests) = policy_app();
{
let mut intent = app.world_mut().resource_mut::<LoginIntent>();
intent.creds = Some(("player".into(), "secret".into()));
intent.park = IoPark::Active;
}
app.world_mut().write_message(DisconnectedMessage {
reason: "logged out".into(),
end: benilla_protocol::SessionEnd::LoggedOut,
session_over: false,
});
app.update();
app.update();
let intent = app.world().resource::<LoginIntent>();
assert!(
intent.creds.is_some(),
"a logout keeps the account signed in"
);
assert!(
intent.park == IoPark::Active && !intent.in_flight && intent.retry_at.is_none(),
"and submits nothing: the link it entered by is still up",
);
assert!(
requests.try_recv().is_err(),
"no login request reached the IO thread"
);
assert!(
app.world().resource::<GlueDialog>().kind.is_none(),
"with no dialog: nothing went wrong",
);
}
/// A refused character login's roster comes on the kept link right behind the refusal; it must
/// not close the refusal's `Error`.
#[test]
fn an_arriving_roster_closes_the_connecting_dialog_but_not_an_error() {
let mut app = App::new();
app.add_plugins((MinimalPlugins, bevy::state::app::StatesPlugin))
.insert_state(ClientState::CharSelect)
.init_resource::<LoginIntent>()
.init_resource::<GlueDialog>()
.add_message::<CharListMessage>()
.add_message::<WorldAuthMessage>()
.add_systems(Update, to_select_on_roster);
// The refusal's dialog survives the roster behind it.
app.world_mut()
.resource_mut::<GlueDialog>()
.open_error("World server is down");
app.world_mut().write_message(CharListMessage {
characters: Vec::new(),
realm: None,
cause: benilla_protocol::RosterCause::Park,
epoch: 0,
generation: 0,
});
app.update();
let dialog = app.world().resource::<GlueDialog>();
assert_eq!(dialog.kind, Some(DialogKind::Error));
assert_eq!(dialog.text, "World server is down");
// The connecting dialog still closes.
app.world_mut()
.resource_mut::<GlueDialog>()
.open_status("Connecting");
app.world_mut().write_message(CharListMessage {
characters: Vec::new(),
realm: None,
cause: benilla_protocol::RosterCause::Park,
epoch: 0,
generation: 0,
});
app.update();
assert!(app.world().resource::<GlueDialog>().kind.is_none());
}
/// The login's success edge is the authentication under the stock screens, whose roster comes
/// only after the screen is up and asks, and the roster under the native ones. Until the stock
/// edge the state stays `Login`, so the screen's Exit still abandons the attempt.
#[test]
fn the_stock_login_ends_at_the_authentication_and_the_native_at_the_roster() {
use crate::net::WorldAuthMessage;
let roster = || CharListMessage {
characters: Vec::new(),
realm: None,
cause: benilla_protocol::RosterCause::Asked,
epoch: 1,
generation: 0,
};
let auth = || WorldAuthMessage {
realm: benilla_protocol::RealmInfo {
name: "Fake".into(),
address: "127.0.0.1:8085".into(),
realm_type: 0,
flags: 0,
population: 0.0,
characters: 0,
category: 1,
id: 1,
},
epoch: 1,
generation: 0,
};
for stock in [true, false] {
let mut app = App::new();
app.add_plugins((MinimalPlugins, bevy::state::app::StatesPlugin))
.insert_state(ClientState::Login)
.insert_resource(crate::ui_script::glue::GlueStock(stock))
.init_resource::<LoginIntent>()
.init_resource::<GlueDialog>()
.add_message::<CharListMessage>()
.add_message::<WorldAuthMessage>()
.add_systems(Update, to_select_on_roster);
let (first, second) = (
|app: &mut App| {
if stock {
app.world_mut().write_message(roster());
} else {
app.world_mut().write_message(auth());
}
},
|app: &mut App| {
if stock {
app.world_mut().write_message(auth());
} else {
app.world_mut().write_message(roster());
}
},
);
// The message of the other policy's edge leaves the screen where it is.
first(&mut app);
app.update();
app.update();
assert_eq!(
*app.world().resource::<State<ClientState>>().get(),
ClientState::Login,
"stock {stock}"
);
second(&mut app);
app.update();
app.update();
assert_eq!(
*app.world().resource::<State<ClientState>>().get(),
ClientState::CharSelect,
"stock {stock}"
);
}
}
/// An attempt a Cancel or a disconnect has passed since is no edge: the screen stays on login
/// and the session that parked behind its back is abandoned, under either policy.
#[test]
fn a_login_edge_behind_the_abandon_generation_abandons_the_session() {
use crate::net::WorldAuthMessage;
for stock in [true, false] {
let (tx, rx) = crossbeam_channel::unbounded();
let mut app = App::new();
app.add_plugins((MinimalPlugins, bevy::state::app::StatesPlugin))
.insert_state(ClientState::Login)
.insert_resource(crate::ui_script::glue::GlueStock(stock))
.insert_resource(LoginAbandon(std::sync::Arc::new(
std::sync::atomic::AtomicU64::new(1),
)))
.insert_resource(crate::net::CharPick(tx.into()))
.init_resource::<LoginIntent>()
.init_resource::<GlueDialog>()
.add_message::<CharListMessage>()
.add_message::<WorldAuthMessage>()
.add_systems(Update, to_select_on_roster);
let send = |app: &mut App, generation: u64| {
if stock {
app.world_mut().write_message(WorldAuthMessage {
realm: benilla_protocol::RealmInfo {
name: "Fake".into(),
address: "127.0.0.1:8085".into(),
realm_type: 0,
flags: 0,
population: 0.0,
characters: 0,
category: 1,
id: 1,
},
epoch: 1,
generation,
});
} else {
app.world_mut().write_message(CharListMessage {
characters: Vec::new(),
realm: None,
cause: benilla_protocol::RosterCause::Park,
epoch: 1,
generation,
});
}
};
send(&mut app, 0);
app.update();
app.update();
assert_eq!(
*app.world().resource::<State<ClientState>>().get(),
ClientState::Login,
"stock {stock}: a stale edge"
);
assert!(
matches!(rx.try_recv(), Ok(crate::net::CharRequest::Abandon)),
"stock {stock}: the parked session is abandoned"
);
send(&mut app, 1);
app.update();
app.update();
assert_eq!(
*app.world().resource::<State<ClientState>>().get(),
ClientState::CharSelect,
"stock {stock}: the current attempt's edge"
);
assert!(rx.try_recv().is_err());
}
}
/// An unattended run still reconnects on a lost session; the verdict rides the message.
#[test]
fn an_unattended_run_still_reconnects_on_its_own() {
let (mut app, _requests) = policy_app();
app.world_mut().resource_mut::<LoginIntent>().creds =
Some(("probe1".into(), "secret".into()));
app.world_mut().write_message(DisconnectedMessage {
reason: "disconnected: connection reset".into(),
end: benilla_protocol::SessionEnd::Lost,
session_over: false,
});
app.update();
let intent = app.world().resource::<LoginIntent>();
assert!(intent.creds.is_some());
assert_eq!(
intent.retry_at,
Some(RETRY_DELAY_SECS),
"paced by the flat 3 s off a zeroed clock, not fired on the spot",
);
assert!(app.world().resource::<GlueDialog>().kind.is_none());
}
/// Each attempt dials the realmlist current at its submit.
#[test]
fn a_submitted_attempt_carries_the_configured_realmlist() {
let (mut app, requests) = policy_app();
app.world_mut()
.insert_resource(crate::realmlist::Realmlist::unpinned(
"logon.example.org:3725",
));
{
let mut intent = app.world_mut().resource_mut::<LoginIntent>();
intent.creds = Some(("player".into(), "secret".into()));
intent.retry_at = Some(0.0);
}
app.update();
let sent = requests.try_recv().expect("the policy submitted");
assert_eq!(sent.user, "player");
assert_eq!(
sent.host, "logon.example.org:3725",
"the attempt dials what the realmlist says, not a value latched at spawn",
);
app.world_mut()
.insert_resource(crate::realmlist::Realmlist::unpinned(
"elsewhere.example.org",
));
{
let mut intent = app.world_mut().resource_mut::<LoginIntent>();
intent.in_flight = false;
intent.retry_at = Some(0.0);
}
app.update();
assert_eq!(
requests.try_recv().expect("resubmitted").host,
"elsewhere.example.org",
);
}
/// A pasted `realmlist.wtf` line becomes the session's address; with no VM, nothing persists.
#[test]
fn the_realmlist_dialog_takes_what_was_typed() {
let mut realmlist = crate::realmlist::Realmlist::unpinned("localhost");
assert!(accept_realmlist(
r#" SET realmlist "logon.example.org" "#,
&mut realmlist,
None,
));
assert_eq!(realmlist.address(), "logon.example.org");
}
#[test]
fn a_bad_address_leaves_the_realmlist_alone() {
let mut realmlist = crate::realmlist::Realmlist::unpinned("localhost");
for typed in ["", " ", "logon.example.org and more", "host:notaport"] {
assert!(
!accept_realmlist(typed, &mut realmlist, None),
"{typed:?} is not an address",
);
assert_eq!(realmlist.address(), "localhost");
}
}
/// The wiring from the widget's published press to this screen's answer.
#[test]
fn a_published_press_reaches_the_login_screens_answer() {
use crate::glue::dialog::GlueDialogAnswer;
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.init_resource::<LoginIntent>()
.init_resource::<GlueDialog>()
.insert_resource(crate::realmlist::Realmlist::unpinned("localhost"))
.insert_resource(LoginAbandon(std::sync::Arc::new(
std::sync::atomic::AtomicU64::new(0),
)))
.add_message::<GlueDialogAnswer>()
.add_systems(Update, answer_dialog);
// The status dialog's Cancel abandons the attempt and forgets the credentials.
app.world_mut().resource_mut::<LoginIntent>().creds =
Some(("player".into(), "secret".into()));
app.world_mut().resource_mut::<LoginIntent>().in_flight = true;
app.world_mut()
.resource_mut::<GlueDialog>()
.open_status("Connecting");
app.world_mut().write_message(GlueDialogAnswer {
kind: DialogKind::Status,
button1: true,
button2: false,
});
app.update();
let intent = app.world().resource::<LoginIntent>();
assert!(
!intent.in_flight,
"the cancelled attempt is no longer in flight"
);
assert!(intent.creds.is_none(), "and it does not silently resubmit");
assert_eq!(
app.world()
.resource::<LoginAbandon>()
.0
.load(std::sync::atomic::Ordering::SeqCst),
1,
"the abandon generation moved, so the attempt still in flight is discarded",
);
assert!(app.world().resource::<GlueDialog>().kind.is_none());
// The realmlist editor's Okay: the typed address becomes the session's.
app.world_mut()
.resource_mut::<GlueDialog>()
.open_realmlist("Address of realm list server", "localhost");
app.world_mut()
.resource_mut::<GlueDialog>()
.edit
.set_text("logon.example.org");
app.world_mut().write_message(GlueDialogAnswer {
kind: DialogKind::Realmlist,
button1: true,
button2: false,
});
app.update();
assert_eq!(
app.world()
.resource::<crate::realmlist::Realmlist>()
.address(),
"logon.example.org",
);
assert!(app.world().resource::<GlueDialog>().kind.is_none());
// Okay over a bad address keeps the dialog up with the prompt replaced.
app.world_mut()
.resource_mut::<GlueDialog>()
.open_realmlist("Address of realm list server", "logon.example.org");
app.world_mut()
.resource_mut::<GlueDialog>()
.edit
.set_text("not an address at all");
app.world_mut().write_message(GlueDialogAnswer {
kind: DialogKind::Realmlist,
button1: true,
button2: false,
});
app.update();
let dialog = app.world().resource::<GlueDialog>();
assert_eq!(dialog.kind, Some(DialogKind::Realmlist), "it stays open");
assert_eq!(dialog.text, REALMLIST_BAD, "saying why");
assert_eq!(
app.world()
.resource::<crate::realmlist::Realmlist>()
.address(),
"logon.example.org",
"and the address it refused is unchanged",
);
}
/// The editor opens on the current address, selected whole.
#[test]
fn opening_the_editor_preselects_the_current_address() {
let mut dialog = GlueDialog::default();
dialog.open_realmlist("Address of realm list server", "logon.example.org");
assert_eq!(dialog.kind, Some(DialogKind::Realmlist));
assert_eq!(dialog.edit.text, "logon.example.org");
assert_eq!(
dialog.edit.selected_text().as_deref(),
Some("logon.example.org")
);
assert_eq!(dialog.edit.max_letters, crate::realmlist::MAX_LETTERS);
assert!(!dialog.edit.password, "an address is not a secret");
}
fn exited(app: &mut App, cursor: &mut bevy::ecs::message::MessageCursor<AppExit>) -> bool {
let msgs = app.world().resource::<Messages<AppExit>>();
cursor.read(msgs).next().is_some()
}
/// Drive one refusal through the policy; whether it exited.
fn refusal_exits(typed: bool) -> bool {
let (mut app, _requests) = policy_app();
let mut cursor = bevy::ecs::message::MessageCursor::<AppExit>::default();
app.update();
let _ = exited(&mut app, &mut cursor);
app.world_mut().resource_mut::<LoginIntent>().announced = typed;
app.world_mut().write_message(LoginFailedMessage {
refusal: Some(LoginRefusal::Logon(0x05)),
reason: "server rejected logon: result 0x05".into(),
terminal: false,
dial: None,
});
app.update();
assert_eq!(
app.world().resource::<GlueDialog>().kind,
Some(DialogKind::Error),
"every refusal shows the dialog, exit or not",
);
exited(&mut app, &mut cursor)
}
/// Neither a typed refusal nor env credentials alone end the run: only an untyped attempt in
/// a run that declares `WOW_UNATTENDED` does.
#[test]
fn a_typed_password_refusal_never_exits() {
let _lock = crate::local_state::test_env::ENV_LOCK.lock();
let _smoke = crate::local_state::test_env::EnvGuard::unset("WOW_LOGIN_SMOKE");
// A person's own launch line (as `.cargo/config.toml`'s example), declaring nobody absent.
let _user = crate::local_state::test_env::EnvGuard::set("WOW_USER", "player");
let _pass = crate::local_state::test_env::EnvGuard::set("WOW_PASS", "secret");
let _char = crate::local_state::test_env::EnvGuard::set("WOW_CHAR", "Hero");
let _decl = crate::local_state::test_env::EnvGuard::unset("WOW_UNATTENDED");
let _cap = crate::local_state::test_env::EnvGuard::unset("WOW_CAPTURE");
let _rig = crate::local_state::test_env::EnvGuard::unset("WOW_RIG");
assert!(
crate::run_mode::env_login() && !crate::run_mode::unattended(),
"the fixture must be a player's env: credentials, nobody declared away",
);
assert!(
!refusal_exits(false),
"an env-credentialled run with a person in it shows the dialog, it does not exit",
);
// A run that declares itself driverless.
let _decl = crate::local_state::test_env::EnvGuard::set("WOW_UNATTENDED", "1");
assert!(
refusal_exits(false),
"an attempt nobody typed, in a run nobody is in, still exits non-zero",
);
assert!(
!refusal_exits(true),
"a password typed at the screen is attended by direct evidence — dialog, another go",
);
}
#[test]
fn a_dead_url_is_cut_at_its_clause() {
// `LOGIN_UNKNOWN_ACCOUNT`, verbatim from `GlueStrings.lua`.
assert_eq!(
without_dead_url(
"The information you have entered is not valid. Please check the spelling of the \
account name and password. If you need help in retrieving a lost or stolen \
password and account, see www.worldofwarcraft.com for more information.",
),
"The information you have entered is not valid. Please check the spelling of the \
account name and password. If you need help in retrieving a lost or stolen password \
and account.",
);
assert_eq!(
without_dead_url(
"This World of Warcraft account has been closed and is no longer available for \
use. Please go to http://www.worldofwarcraft.com/misc/banned.html for further \
information. ",
),
"This World of Warcraft account has been closed and is no longer available for use.",
);
// Nothing to cut: borrowed, not rebuilt.
let clean = "You have used up your prepaid time for this account.";
assert!(matches!(
without_dead_url(clean),
std::borrow::Cow::Borrowed(_)
));
assert_eq!(without_dead_url(clean), clean);
}
/// `AUTH_BANNED`'s address sits behind a dash, with no clause to cut at.
#[test]
fn an_address_with_no_clause_boundary_is_left_alone() {
let banned = "This account has been banned for violating the Terms of Use Agreement - \
www.worldofwarcraft.com/termsofuse.shtml. Please contact our GM department.";
assert_eq!(without_dead_url(banned), banned);
// The degenerate case cannot produce an empty dialog.
assert_eq!(without_dead_url("www.example.com"), "www.example.com");
assert_eq!(without_dead_url(". www.example.com"), ". www.example.com");
}
#[test]
fn the_login_press_asks_for_the_account_before_the_password() {
let mut form = LoginForm::default();
assert_eq!(login_press(&form), LoginPress::NeedAccount);
form.account.set_text("player");
assert_eq!(login_press(&form), LoginPress::NeedPassword);
form.password.set_text("secret");
assert_eq!(login_press(&form), LoginPress::Submit);
let mut only_pass = LoginForm::default();
only_pass.password.set_text("secret");
assert_eq!(login_press(&only_pass), LoginPress::NeedAccount);
}
#[test]
fn focusing_a_box_selects_all_of_it() {
let mut form = LoginForm::default();
form.account.set_text("remembered");
form.password.set_text("secret");
// Mid-blink, caret off and mid-text: the focus restarts it solid, where it was.
form.account.caret_shown = false;
form.account.blink_accum = 0.3;
form.account.cursor = 3;
form.focus(Field::Account);
assert_eq!(form.focus, Field::Account);
assert_eq!(form.account.selected_text().as_deref(), Some("remembered"));
assert_eq!(form.account.cursor, 3, "HighlightText leaves the caret");
assert!(form.account.caret_shown, "a fresh focus starts solid");
assert_eq!(form.account.blink_accum, 0.0, "with a full half-period");
// Checked as a range: a password box's `selected_text` returns the `*` mask.
form.focus(Field::Password);
assert_eq!(
(form.password.sel_start, form.password.sel_end),
(0, form.password.text.len()),
"the whole password is selected even though it cannot be read back",
);
assert_eq!(
form.account.selected_text(),
None,
"the box that lost the keyboard keeps no highlight",
);
form.focused().insert("x");
assert_eq!(form.password.text, "x");
form.focus(Field::Account);
assert_eq!(
form.password.selected_text(),
None,
"and back the other way"
);
}
/// The realmd map, against the client's table (`0x5b2c90`, key table `0x836b78`).
#[test]
fn fail_text_maps_the_verified_codes() {
let strings = GlueStrings::default(); // empty table: the fallback literals
let logon = |b| fail_text(&strings, Some(LoginRefusal::Logon(b)), None);
// 0x04 and 0x05 share one jump-table arm.
assert!(logon(0x04).starts_with("The information you have entered is not valid."));
assert_eq!(logon(0x05), logon(0x04), "one arm, byte-identical");
assert!(logon(0x04).contains("account name and password"));
assert_eq!(fail_text(&strings, None, None), "Unable to connect");
for code in [0x01, 0x02, 0x0B, 0x0D] {
assert_eq!(logon(code), "Unable to connect", "code {code:#04x}");
}
assert!(logon(0x03).contains("has been closed"));
assert!(logon(0x06).contains("already logged into"));
assert!(logon(0x09).contains("Unable to validate game version"));
assert!(logon(0x0C).contains("temporarily suspended"));
assert!(logon(0x0F).contains("parental controls"));
// Past 0x0F the byte-index table saturates onto the disconnect arm.
for code in [0x10, 0x11, 0x12, 0xEE] {
assert_eq!(logon(code), "Disconnected from server", "code {code:#04x}");
}
}
/// The realmd and world result enums share bytes with different meanings: 0x0C is
/// `AUTH_LOGON_FAILED_SUSPENDED` to realmd and `AUTH_OK` to the world server.
#[test]
fn the_two_auth_enums_do_not_share_a_byte() {
use benilla_protocol::messages as m;
let strings = GlueStrings::default();
let logon = |b| fail_text(&strings, Some(LoginRefusal::Logon(b)), None);
let world = |b| fail_text(&strings, Some(LoginRefusal::World(b)), None);
assert!(logon(0x0C).contains("temporarily suspended"));
assert_eq!(world(m::AUTH_BILLING_ERROR), "Billing system error");
assert_ne!(logon(0x0C), world(0x0C));
// The client's world dispatch (`0x5aa960`).
assert_eq!(world(m::AUTH_INCORRECT_PASSWORD), "Incorrect Password");
assert_eq!(world(m::AUTH_SESSION_EXPIRED), "Session Expired");
assert_eq!(world(m::AUTH_SERVER_SHUTTING_DOWN), "Server Shutting Down");
assert_eq!(world(m::AUTH_ALREADY_LOGGING_IN), "Already Logging In");
assert_eq!(
world(m::AUTH_UNAVAILABLE),
"System unavailable - Please try again later"
);
// Rows of the reference's `OKAY_WITH_URL` table.
assert!(world(m::AUTH_BANNED).contains("banned"));
assert!(world(m::AUTH_SUSPENDED).contains("temporarily suspended"));
assert!(world(m::AUTH_PARENTAL_CONTROL).contains("parental controls"));
// An unknown code lands on the catch-all.
assert_eq!(world(0xEE), "Authentication failed");
}
/// Each dial verdict gets the reference's string for its condition, with the address under it.
#[test]
fn a_dial_failure_says_which_failure_it_was() {
let strings = GlueStrings::default();
let down = DialFailure {
address: "127.0.0.1:3724".into(),
unresolved: false,
};
assert_eq!(
fail_text(&strings, None, Some(&down)),
"Login Server Down\n127.0.0.1:3724",
"the address resolved and nothing answered — editing the address will not help",
);
let missing = DialFailure {
address: "logon.nonesuch.example:3724".into(),
unresolved: true,
};
assert_eq!(
fail_text(&strings, None, Some(&missing)),
"Invalid Login Server\nlogon.nonesuch.example:3724",
"the name resolved to nothing — this one IS the address",
);
// A refusal keeps its own string.
assert!(fail_text(&strings, Some(LoginRefusal::Logon(0x05)), None)
.starts_with("The information you have entered is not valid."));
}
/// Save, load and clear round-trip through the file (`Get`/`SetSavedAccountName`).
#[test]
fn saved_account_round_trips() {
let dir = std::env::temp_dir().join(format!(
"benilla-login-test-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
let path = dir.join("account");
let _ = std::fs::remove_dir_all(&dir);
assert_eq!(load_saved_account_from(&path), "");
// The write creates the folder, as a first run must.
save_account_to(&path, "PLAYER");
assert_eq!(load_saved_account_from(&path), "PLAYER");
save_account_to(&path, "");
assert_eq!(load_saved_account_from(&path), "");
assert!(!path.exists(), "clearing the name removes the file");
let _ = std::fs::remove_dir_all(&dir);
}
/// The focused box blinks on the shared 0.5 s period; the other is left alone.
#[test]
fn the_focused_box_caret_blinks() {
let mut app = App::new();
app.init_resource::<Time>()
.init_resource::<LoginForm>()
.init_resource::<GlueDialog>()
.add_systems(Update, tick_login_caret);
let past_the_period = |app: &mut App| {
app.world_mut()
.resource_mut::<Time>()
.advance_by(std::time::Duration::from_millis(600));
app.update();
app.world().resource::<LoginForm>().account.caret_shown
};
// Account has the focus by default.
assert!(!past_the_period(&mut app), "the first period turns it off");
assert!(past_the_period(&mut app), "the second turns it back on");
// The unfocused box never accumulates, so focusing it lands on a solid caret.
assert_eq!(
app.world().resource::<LoginForm>().password.blink_accum,
0.0
);
}
}