- lava and slime surfaces creep on the building path — the two liquid types the reference animates, and only those - particle blend 1 is AlphaKey: the rock elemental throws tumbling chips instead of flat pale squares - standing in a sealed room, outdoor creatures are never submitted to the scene at all — the reference's own election, not a hide - an animation clip's arm frame fires no event keys, matching the client's walker rule frame for frame Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
527 lines
26 KiB
Rust
527 lines
26 KiB
Rust
//! The session preflight (decision 0649) — one always-on banner naming **what body we just logged
|
|
//! into**, plus loud warnings for the avatar states that silently invalidate a session's work.
|
|
//!
|
|
//! This is instrumentation for the *reader of the log*, not a game system. It exists because three
|
|
//! things cost a session ten minutes each and none of them announces itself:
|
|
//!
|
|
//! 1. **The character is dead or a ghost**, left that way by an earlier session. Nothing can be
|
|
//! interacted with, attacked, looted or talked to; the world renders through the death filter;
|
|
//! movement is rooted (unreleased) or ghost-speed (released). Every symptom reads like a client
|
|
//! bug, and the client is fine.
|
|
//! 2. **GM mode is on** — which every probe character used to carry by default, as the only guard
|
|
//! against unattended aggro killing it. vmangos's `Player::SetGameMaster` re-templates the player
|
|
//! to **faction template 35** and freezes the mirror timers, so nothing is hostile, nothing
|
|
//! aggros, fall/environmental damage is skipped and breath/fatigue never tick.
|
|
//! FactionTemplate.dbc row 35 (VERIFIED from the extracted DBC): faction 31 "Friendly", own group
|
|
//! mask 0, enemy mask 0 — it is hostile to nothing and nothing is hostile to it. Any hostility,
|
|
//! reaction-colour, nameplate, aggro, threat, damage or drowning measurement taken with GM on is
|
|
//! simply wrong, quietly. It also silently suspends the **indoor dismount**: the outdoor-only
|
|
//! aura sweep is `!IsGameMaster()`-gated, so a GM rides into the Goldshire inn and stays mounted
|
|
//! (decision 0934). It is now **off** by default: [`crate::probe_shield`] keeps the body
|
|
//! alive without poisoning any of that (decision 0677), and this banner reports which it is.
|
|
//! 3. **Movement is server-blocked** — rooted, stunned, confused, fleeing, or mid-taxi-flight. The
|
|
//! controller ignores input and the honest report is "the mover is broken".
|
|
//! 4. **The run has no server at all** ([`offline_notice`], decision 0728). Capture mode passes
|
|
//! `NetPlugin { connect: false }`, so no IO thread is spawned and the drain receives nothing —
|
|
//! the whole packet path (`net::apply`, the movement wire, every `MSG_MOVE_*`) simply does not
|
|
//! execute. This one is the mirror image of the other three: they make a healthy client *look*
|
|
//! broken, while this makes an untested change *look proven*. A capture run over a change to the
|
|
//! packet path exits clean no matter what that change does, and "clean run" then lands in a
|
|
//! commit message as evidence it never was (twice in one day — decisions 0725 and 0726, both
|
|
//! pure wire work, both reported with a capture run behind them). The banner above cannot catch
|
|
//! it: that one waits on [`EnteredWorldMessage`], which needs a server. So this notice fires at
|
|
//! **startup**, before anything can be concluded from the run.
|
|
//!
|
|
//! The banner is **not env-gated**: a warning nobody knows to switch on is not a warning. It costs
|
|
//! one line per world entry when everything is fine, and it re-fires on every re-entry (a relog, a
|
|
//! `.character race` forced logout, a reconnect) because the state can have changed.
|
|
//!
|
|
//! The same audience — the reader of the log — is why [`benilla_world::build_id::banner`] exists.
|
|
//! It is **not** registered here any more (decision 1179): this module is dev-only since 1174, and
|
|
//! "which build produced this log" is the first thing a report from *someone else's machine* has to
|
|
//! establish — which is precisely the player build. The banner now registers beside the stamp
|
|
//! itself, in `lib::run`, where it is always compiled.
|
|
//!
|
|
//! The other half of decision 0649 — the pre-connect **account guard**, which keeps a session
|
|
//! running inside a worktree pool slot from logging in as somebody else's account — lives in
|
|
//! [`crate::run_mode`] now, not here: it is consulted by the login policy, and 1174's seam does not
|
|
//! let gameplay call an instrument. Its reasoning went with it.
|
|
|
|
use bevy::prelude::*;
|
|
|
|
use crate::area::AreaTableRes;
|
|
use crate::names::NameCache;
|
|
use crate::net::{EnteredWorldMessage, ObjectStore, SelfGuid, SelfPlayer};
|
|
use crate::probe_shield::{ProbeShield, ShieldReport};
|
|
use benilla_world::world_map::CurrentMap;
|
|
|
|
/// `PLAYER_FLAGS_GM` (vmangos `Player.h`) — set by `SetGameMaster(true)` alongside the faction-35
|
|
/// re-template. PUBLIC, so it rides our own descriptor like any other player flag.
|
|
const PLAYER_FLAGS_GM: u32 = 0x0000_0008;
|
|
|
|
/// The `UNIT_FIELD_FLAGS` bits that mean "the server is driving, or refusing to let you drive"
|
|
/// (vmangos `UnitDefines.h`) — each is a distinct reason the mover looks broken.
|
|
const MOVE_BLOCKERS: &[(u32, &str)] = &[
|
|
(0x0002_0000, "PACIFIED (no melee, no pacify-blocked casts)"),
|
|
(0x0004_0000, "STUNNED"),
|
|
(
|
|
0x0010_0000,
|
|
"on a TAXI FLIGHT (input ignored for the whole ride)",
|
|
),
|
|
(0x0020_0000, "DISARMED (weapon abilities refuse)"),
|
|
(0x0040_0000, "CONFUSED (the server drives the movement)"),
|
|
(0x0080_0000, "FLEEING (the server drives the movement)"),
|
|
(0x0100_0000, "POSSESSED (another unit holds the reins)"),
|
|
];
|
|
|
|
/// Worth naming on entry: an unattended probe that logs in already fighting is the exact shape of
|
|
/// the accident the unattended-combat ban exists for (method.md). The bit itself is declared once
|
|
/// ([`crate::player`]).
|
|
use crate::player::UNIT_FLAG_IN_COMBAT;
|
|
|
|
/// `UNIT_FLAG_SILENCED` — casts silently refuse.
|
|
const UNIT_FLAG_SILENCED: u32 = 0x0000_2000;
|
|
|
|
/// The GM faction template vmangos swaps in with GM mode (`SetFactionTemplateId(35)`).
|
|
const GM_FACTION_TEMPLATE: u32 = 35;
|
|
|
|
/// How long after world entry we keep waiting for the zone name before reporting without it. The
|
|
/// descriptor lands in a few hundred ms but the area id only resolves once the tile under us has
|
|
/// streamed (~2 s), and "Eastern Plaguelands" is the line's whole orienting value — a raw map id
|
|
/// and a coordinate triple do not tell a reader at a glance that this character is nowhere useful.
|
|
const ZONE_WAIT_SECS: f32 = 4.0;
|
|
|
|
/// How long we let an in-flight `.gm off` settle before banner-ing the GM flag anyway.
|
|
///
|
|
/// `WOW_GM=off` is a **round trip** and it loses the race with the descriptor: the shield sends
|
|
/// `.gm off` at world entry and the server's answer lands ~0.9 s later, while the self descriptor
|
|
/// is here in ~0.3 s. Without this grace the banner shouts "GM MODE IS ON — any damage or drowning
|
|
/// reading taken now is wrong" at precisely the run that asked for GM off, which reads as *discard
|
|
/// this measurement* and is exactly backwards (a 2026-08-13 lava probe hit it). Wait, then report
|
|
/// what actually held — a `.gm off` that never lands is still warned about, one grace later.
|
|
const GM_OFF_WAIT_SECS: f32 = 3.0;
|
|
|
|
/// The hard backstop: report whatever we have this long after world entry. Only reached when
|
|
/// something is badly wrong with the stream, and reporting late beats never reporting.
|
|
const DESCRIPTOR_WAIT_SECS: f32 = 8.0;
|
|
|
|
pub(crate) struct PreflightPlugin;
|
|
|
|
impl Plugin for PreflightPlugin {
|
|
fn build(&self, app: &mut App) {
|
|
app.init_resource::<Preflight>()
|
|
.add_systems(Startup, offline_notice)
|
|
.add_systems(
|
|
Update,
|
|
report_session.after(benilla_world::schedule::WorldStage::Net),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Say out loud, once at startup, that this run has **no server** — so nobody reads its clean exit
|
|
/// as evidence about code that never ran (module header §4, decision 0728).
|
|
///
|
|
/// Deliberately a `warn!` rather than an `info!`. A netless run is completely normal and completely
|
|
/// fine — it is what every visual capture wants — so this is not a complaint about the run. It is
|
|
/// aimed at the *reader*, who is usually a session skimming for the word "error" before pasting
|
|
/// "clean run" into a commit message, and `warn` is the lowest level that survives that skim.
|
|
fn offline_notice(net: Option<Res<crate::net::NetOffline>>) {
|
|
if net.is_none() {
|
|
return;
|
|
}
|
|
warn!(
|
|
"preflight: NET OFF — no IO thread this run, so the drain gets no packets and NOTHING on \
|
|
the wire path executes (net::apply, the movement stream, every MSG_MOVE_*). Fine for a \
|
|
visual capture; NOT evidence for a change to any of it. method.md's gate is a clean run \
|
|
of the AFFECTED path — for wire work that means a live server run."
|
|
);
|
|
}
|
|
|
|
/// The banner's once-per-entry latch: armed by [`EnteredWorldMessage`], disarmed when the report
|
|
/// goes out (or when the wait expires).
|
|
#[derive(Resource, Default)]
|
|
struct Preflight {
|
|
/// `Time::elapsed_secs` at the world entry we still owe a report for; `None` = nothing pending.
|
|
armed_at: Option<f32>,
|
|
}
|
|
|
|
/// Wait for the self descriptor after each world entry, then print the banner once.
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn report_session(
|
|
mut state: ResMut<Preflight>,
|
|
mut entered: MessageReader<EnteredWorldMessage>,
|
|
time: Res<Time>,
|
|
self_q: Query<(&ObjectStore, &Transform), With<SelfPlayer>>,
|
|
self_guid: Res<SelfGuid>,
|
|
names: Res<NameCache>,
|
|
map: Option<Res<CurrentMap>>,
|
|
world: benilla_world::world_point::WorldPoint,
|
|
area_table: Option<Res<AreaTableRes>>,
|
|
shield: Res<ProbeShield>,
|
|
) {
|
|
if entered.read().next().is_some() {
|
|
state.armed_at = Some(time.elapsed_secs());
|
|
}
|
|
let Some(armed_at) = state.armed_at else {
|
|
return;
|
|
};
|
|
let expired = time.elapsed_secs() - armed_at >= DESCRIPTOR_WAIT_SECS;
|
|
let Ok((store, transform)) = self_q.single() else {
|
|
if expired {
|
|
state.armed_at = None;
|
|
warn!("preflight: no self descriptor {DESCRIPTOR_WAIT_SECS:.0}s after entering the world — the avatar never streamed in");
|
|
}
|
|
return;
|
|
};
|
|
// MAXHEALTH is always in the login snapshot; an empty store means the create block hasn't been
|
|
// applied yet, and reading it would report a level-0 dead corpse of no race.
|
|
if store.0.unit_max_health().unwrap_or(0) == 0 && !expired {
|
|
return;
|
|
}
|
|
// `CurrentArea` is the FINEST area under us ("Darrowshire"), which on its own can be unplaceable;
|
|
// the parent zone ("Eastern Plaguelands") is what orients a reader, so print zone / subzone.
|
|
let zone = world
|
|
.area()
|
|
.zip(area_table.as_deref())
|
|
.map(|(id, cat)| {
|
|
let sub = cat.0.name(id);
|
|
let top = cat.0.top_zone(id).and_then(|z| cat.0.name(z));
|
|
match (top, sub) {
|
|
(Some(top), Some(sub)) if top != sub => format!(" \"{top} / {sub}\""),
|
|
(Some(n), _) | (_, Some(n)) => format!(" \"{n}\""),
|
|
_ => String::new(),
|
|
}
|
|
})
|
|
.filter(|z| !z.is_empty());
|
|
if zone.is_none() && time.elapsed_secs() - armed_at < ZONE_WAIT_SECS {
|
|
return; // the tile under us hasn't streamed yet — give the zone name its grace
|
|
}
|
|
// The other in-flight state the banner would otherwise misreport: a requested `.gm off` that
|
|
// the server has not answered yet (see [`GM_OFF_WAIT_SECS`]).
|
|
if hold_for_gm_off(
|
|
store.0.player_flags() & PLAYER_FLAGS_GM != 0,
|
|
crate::probe_shield::wants_gm_off(),
|
|
shield.report(),
|
|
time.elapsed_secs() - armed_at,
|
|
) {
|
|
return;
|
|
}
|
|
state.armed_at = None;
|
|
|
|
let name = self_guid
|
|
.0
|
|
.and_then(|g| names.peek(g))
|
|
.unwrap_or("<unnamed>");
|
|
let race = store
|
|
.0
|
|
.unit_race()
|
|
.and_then(|r| crate::ui_unit::race_names(r).map(|(d, _)| d))
|
|
.unwrap_or("?");
|
|
let class = store
|
|
.0
|
|
.unit_class()
|
|
.and_then(|c| crate::ui_unit::class_names(c).map(|(d, _)| d))
|
|
.unwrap_or("?");
|
|
let zone = zone.unwrap_or_default();
|
|
// Printed as the raw WoW triple, which is what `.go xyz <x> <y> <z> <map>` takes — the whole
|
|
// point of putting the position in the banner is that it can be pasted back.
|
|
let [wx, wy, wz] = benilla_assets::coords::bevy_to_wow(transform.translation);
|
|
info!(
|
|
"preflight: {name} — level {lvl} {race} {class}, {hp}/{maxhp} hp, map {map}{zone} @ [{wx:.1}, {wy:.1}, {wz:.1}], faction template {faction}{shielded}",
|
|
lvl = store.0.unit_level().unwrap_or(0),
|
|
hp = store.0.unit_health().unwrap_or(0),
|
|
maxhp = store.0.unit_max_health().unwrap_or(0),
|
|
map = map.map_or(-1, |m| m.0 as i64),
|
|
faction = store.0.unit_faction_template().unwrap_or(0),
|
|
// A shielded body is good news, so it rides the banner rather than the warning ladder —
|
|
// but it is still *stated*, because "can this thing die?" is the first question an
|
|
// unattended run needs answered (decision 0677).
|
|
shielded = match shield.report() {
|
|
ShieldReport::Armed => ", SHIELDED (cannot die)",
|
|
ShieldReport::Arming => ", shield arming",
|
|
_ => "",
|
|
},
|
|
);
|
|
|
|
for line in findings(&store.0, shield.report()) {
|
|
warn!("preflight: {line}");
|
|
}
|
|
}
|
|
|
|
/// Whether to hold the banner for a `.gm off` that has gone out but not been answered
|
|
/// ([`GM_OFF_WAIT_SECS`] is the why). Pure, so the race it guards is testable without an app.
|
|
///
|
|
/// All four clauses matter. Only a run that *asked* for GM off waits; only a body the shield
|
|
/// actually commands (`probe<N>`) can have a `.gm off` in flight at all — on the director's own
|
|
/// account `WOW_GM` is inert (0677), so waiting there would just delay a true warning; and the
|
|
/// grace expires, so a `.gm off` that never lands is still reported, one grace later.
|
|
fn hold_for_gm_off(gm_flag_set: bool, wants_off: bool, shield: ShieldReport, waited: f32) -> bool {
|
|
gm_flag_set
|
|
&& wants_off
|
|
&& matches!(shield, ShieldReport::Arming | ShieldReport::Armed)
|
|
&& waited < GM_OFF_WAIT_SECS
|
|
}
|
|
|
|
/// Everything about this avatar that will quietly invalidate a session's work, worst first. Pure
|
|
/// over the descriptor (plus the one piece of state that rides no descriptor field — the probe
|
|
/// shield, decision 0677) so the whole ladder is unit-testable.
|
|
fn findings(
|
|
fields: &benilla_protocol::messages::ObjectFields,
|
|
shield: ShieldReport,
|
|
) -> Vec<String> {
|
|
let mut out = Vec::new();
|
|
let unit_flags = fields.unit_flags();
|
|
|
|
// Dead and ghost are mutually exclusive on the wire (a released ghost's health is 1, decision
|
|
// 0308 §1) — report whichever holds, never both.
|
|
if fields.unit_is_dead() {
|
|
out.push(
|
|
"THE CHARACTER IS DEAD (health 0, corpse not released) — the mover is server-rooted \
|
|
and nothing can be cast, attacked, looted or interacted with. Fix it before anything \
|
|
else: WOW_PROBE_CHAT=\".revive\" (probe accounts are gmlevel 6)."
|
|
.into(),
|
|
);
|
|
} else if fields.player_is_ghost() {
|
|
out.push(
|
|
"THE CHARACTER IS A GHOST (spirit released, corpse elsewhere) — the world renders \
|
|
through the death filter, NPCs and objects refuse every interaction, and movement \
|
|
runs at ghost speed on water. Fix it before anything else: WOW_PROBE_CHAT=\".revive\"."
|
|
.into(),
|
|
);
|
|
}
|
|
|
|
// The shield's bad states are findings; being armed is good news and rides the banner line.
|
|
match shield {
|
|
ShieldReport::Disabled => out.push(
|
|
"THE PROBE SHIELD IS OFF (WOW_GOD=off) — this character CAN die, so an unattended run \
|
|
can leave a corpse for the next session. Deliberate for a death-arc test; unset \
|
|
WOW_GOD for anything else (decision 0677)."
|
|
.into(),
|
|
),
|
|
ShieldReport::Unconfirmed => out.push(
|
|
"THE PROBE SHIELD DID NOT CONFIRM — `.cheat god on` went out and the server never \
|
|
answered. Treat this character as mortal and do not leave it parked anywhere hostile."
|
|
.into(),
|
|
),
|
|
ShieldReport::NotOurs | ShieldReport::Arming | ShieldReport::Armed => {}
|
|
}
|
|
|
|
if fields.player_flags() & PLAYER_FLAGS_GM != 0 {
|
|
out.push(format!(
|
|
"GM MODE IS ON — vmangos re-templates the player to faction {GM_FACTION_TEMPLATE} \
|
|
(\"Friendly\", enemy mask 0) and freezes the mirror timers, so NOTHING is hostile, \
|
|
nothing aggros, fall/environmental damage is skipped and breath/fatigue never tick. \
|
|
Any hostility, reaction-colour, nameplate, threat, aggro, damage or drowning reading \
|
|
taken now is wrong. It ALSO suspends the INDOOR DISMOUNT: \
|
|
`CheckAreaExploreAndOutdoor` drops outdoor-only auras only `if (… && \
|
|
!IsGameMaster())`, so a GM rides into a building and stays mounted (decision 0934). {}",
|
|
match shield {
|
|
// GM mode is the DEFAULT (0679) — it is what stops a parked body being mobbed, and
|
|
// the shield is what makes dropping it safe. So this warning is expected on most
|
|
// runs, and its job is to make sure nobody measures hostility through faction 35.
|
|
ShieldReport::Arming | ShieldReport::Armed =>
|
|
"This is the default. Re-run with WOW_GM=off for those readings — safe, \
|
|
because the probe shield (decision 0677) keeps the body alive without it.",
|
|
// Not a probe body — the director's own account, or a bystander. `WOW_GM` would be
|
|
// inert here (the shield only ever commands `probe<N>`, 0677), and saying otherwise
|
|
// sends the reader after a switch that does nothing. The state is also PERSISTED:
|
|
// vmangos saves it in `characters.extra_flags` bit 0 and `GM.LoginState = 2`
|
|
// restores it, so it stays on across logins until somebody turns it off.
|
|
ShieldReport::NotOurs =>
|
|
"WOW_GM does not reach this body — the shield only ever commands probe accounts \
|
|
(0677) — so the way out is typing `.gm off` yourself. It persists across \
|
|
logins (vmangos GM.LoginState = 2), which is why it is on now.",
|
|
_ =>
|
|
"Re-run with WOW_GM=off for those readings. Note the probe shield is NOT up on \
|
|
this run, so an unshielded body with GM mode off can be killed.",
|
|
}
|
|
));
|
|
}
|
|
|
|
let blocked: Vec<&str> = MOVE_BLOCKERS
|
|
.iter()
|
|
.filter(|(bit, _)| unit_flags & bit != 0)
|
|
.map(|(_, label)| *label)
|
|
.collect();
|
|
if !blocked.is_empty() {
|
|
out.push(format!(
|
|
"MOVEMENT IS SERVER-BLOCKED — {} — the controller will look broken because the server \
|
|
is refusing (or driving) the movement, not because the mover is.",
|
|
blocked.join(", ")
|
|
));
|
|
}
|
|
|
|
if unit_flags & UNIT_FLAG_IN_COMBAT != 0 {
|
|
out.push(
|
|
"IN COMBAT on arrival — something is already fighting this character. Do not leave it \
|
|
unattended (method.md's unattended-combat ban): break the fight or move it out."
|
|
.into(),
|
|
);
|
|
}
|
|
if unit_flags & UNIT_FLAG_SILENCED != 0 {
|
|
out.push("SILENCED — spell casts will refuse for as long as the aura holds.".into());
|
|
}
|
|
out
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use benilla_protocol::messages::ObjectFields;
|
|
|
|
/// Build a player descriptor from `(field index, value)` pairs — the wire indices the
|
|
/// accessors read.
|
|
fn player(fields: &[(u16, u32)]) -> ObjectFields {
|
|
ObjectFields::from_pairs(fields)
|
|
}
|
|
|
|
const HEALTH: u16 = 22;
|
|
const MAXHEALTH: u16 = 28;
|
|
const UNIT_FLAGS: u16 = 46;
|
|
const PLAYER_FLAGS: u16 = 190;
|
|
|
|
/// The banner must not accuse a `WOW_GM=off` run of measuring through GM mode while the
|
|
/// `.gm off` it just sent is still in flight — the race [`GM_OFF_WAIT_SECS`] exists for, and
|
|
/// the one a lava probe walked into on 2026-08-13.
|
|
#[test]
|
|
fn the_banner_waits_out_a_gm_off_round_trip_but_not_forever() {
|
|
// The race itself: flag still set, we asked for off, shield is ours, answer not back yet.
|
|
assert!(hold_for_gm_off(true, true, ShieldReport::Arming, 0.4));
|
|
assert!(hold_for_gm_off(true, true, ShieldReport::Armed, 0.4));
|
|
|
|
// …but the grace expires, so a `.gm off` that never lands is still reported.
|
|
assert!(!hold_for_gm_off(
|
|
true,
|
|
true,
|
|
ShieldReport::Armed,
|
|
GM_OFF_WAIT_SECS + 0.1
|
|
));
|
|
|
|
// Nothing else ever waits. GM mode is the DEFAULT (0679): a run that did not ask for it off
|
|
// must be warned immediately, not three seconds late…
|
|
assert!(!hold_for_gm_off(true, false, ShieldReport::Armed, 0.4));
|
|
// …a body the shield does not command (the director's own account) can have no `.gm off`
|
|
// in flight at all, so waiting there would only delay a true warning (0677)…
|
|
assert!(!hold_for_gm_off(true, true, ShieldReport::NotOurs, 0.4));
|
|
assert!(!hold_for_gm_off(true, true, ShieldReport::Disabled, 0.4));
|
|
// …and with the flag already clear there is nothing to wait for.
|
|
assert!(!hold_for_gm_off(false, true, ShieldReport::Armed, 0.4));
|
|
}
|
|
|
|
#[test]
|
|
fn a_healthy_avatar_reports_nothing() {
|
|
let f = player(&[(HEALTH, 60), (MAXHEALTH, 60)]);
|
|
assert!(findings(&f, ShieldReport::Armed).is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn dead_and_ghost_never_report_together() {
|
|
// Dead: health 0 with a real MAXHEALTH.
|
|
let dead = findings(&player(&[(MAXHEALTH, 60)]), ShieldReport::Armed);
|
|
assert_eq!(dead.len(), 1);
|
|
assert!(dead[0].contains("IS DEAD"));
|
|
// Ghost: health 1 and PLAYER_FLAGS_GHOST — the wire never shows both (decision 0308 §1).
|
|
let ghost = findings(
|
|
&player(&[(HEALTH, 1), (MAXHEALTH, 60), (PLAYER_FLAGS, 0x10)]),
|
|
ShieldReport::Armed,
|
|
);
|
|
assert_eq!(ghost.len(), 1);
|
|
assert!(ghost[0].contains("IS A GHOST"));
|
|
}
|
|
|
|
#[test]
|
|
fn gm_mode_is_reported_on_a_perfectly_healthy_avatar() {
|
|
let f = player(&[
|
|
(HEALTH, 60),
|
|
(MAXHEALTH, 60),
|
|
(PLAYER_FLAGS, PLAYER_FLAGS_GM),
|
|
]);
|
|
let out = findings(&f, ShieldReport::Armed);
|
|
assert_eq!(out.len(), 1);
|
|
assert!(out[0].contains("GM MODE IS ON"));
|
|
}
|
|
|
|
#[test]
|
|
fn a_shielded_body_says_nothing_but_an_unshielded_one_does() {
|
|
let healthy = player(&[(HEALTH, 60), (MAXHEALTH, 60)]);
|
|
// Armed is good news: it rides the banner line, never the warning ladder. Neither does a
|
|
// body the shield has no opinion about (the director's own account).
|
|
for quiet in [
|
|
ShieldReport::Armed,
|
|
ShieldReport::Arming,
|
|
ShieldReport::NotOurs,
|
|
] {
|
|
assert!(findings(&healthy, quiet).is_empty(), "{quiet:?}");
|
|
}
|
|
// Both failure shapes are findings, because both mean "this body can die".
|
|
let off = findings(&healthy, ShieldReport::Disabled);
|
|
assert_eq!(off.len(), 1);
|
|
assert!(off[0].contains("SHIELD IS OFF") && off[0].contains("WOW_GOD"));
|
|
let unconfirmed = findings(&healthy, ShieldReport::Unconfirmed);
|
|
assert_eq!(unconfirmed.len(), 1);
|
|
assert!(unconfirmed[0].contains("DID NOT CONFIRM"));
|
|
}
|
|
|
|
#[test]
|
|
fn the_gm_warning_always_names_the_way_out() {
|
|
// GM mode is the default (0679), so this warning fires on nearly every probe run. Its whole
|
|
// job is that nobody measures hostility through faction 35 without being told how to stop —
|
|
// and the old advice ("put it back on when you are done") must not come back, because a
|
|
// session that complies re-poisons every faction reading (0657).
|
|
let gm = player(&[
|
|
(HEALTH, 60),
|
|
(MAXHEALTH, 60),
|
|
(PLAYER_FLAGS, PLAYER_FLAGS_GM),
|
|
]);
|
|
for report in [
|
|
ShieldReport::Armed,
|
|
ShieldReport::Arming,
|
|
ShieldReport::Disabled,
|
|
ShieldReport::Unconfirmed,
|
|
] {
|
|
let out = findings(&gm, report);
|
|
let line = out.iter().find(|l| l.contains("GM MODE IS ON")).unwrap();
|
|
assert!(line.contains("WOW_GM=off"), "{report:?}: {line}");
|
|
assert!(!line.contains("put it back on"), "{report:?}: {line}");
|
|
}
|
|
// …but the way out has to be one that WORKS on this body. `WOW_GM` only ever commands a
|
|
// probe account (0677), so on anyone else's — the director's own, which is exactly who
|
|
// rides into an inn and asks why they are still mounted — the switch is `.gm off`, typed.
|
|
let not_ours = findings(&gm, ShieldReport::NotOurs);
|
|
let line = not_ours
|
|
.iter()
|
|
.find(|l| l.contains("GM MODE IS ON"))
|
|
.unwrap();
|
|
assert!(
|
|
line.contains(".gm off") && !line.contains("WOW_GM=off"),
|
|
"{line}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn the_gm_warning_names_the_indoor_dismount() {
|
|
// The consequence that cost a session (decision 0934): the outdoor-only aura sweep is
|
|
// `!IsGameMaster()`-gated server-side, so a GM never dismounts riding into a building — and
|
|
// the client, whose mount lane is nothing but the MOUNTDISPLAYID watcher, has no say in it.
|
|
let gm = player(&[
|
|
(HEALTH, 60),
|
|
(MAXHEALTH, 60),
|
|
(PLAYER_FLAGS, PLAYER_FLAGS_GM),
|
|
]);
|
|
let line = findings(&gm, ShieldReport::Armed).remove(0);
|
|
assert!(line.contains("INDOOR DISMOUNT"), "{line}");
|
|
}
|
|
|
|
#[test]
|
|
fn every_move_blocker_is_named_in_one_line() {
|
|
let f = player(&[
|
|
(HEALTH, 60),
|
|
(MAXHEALTH, 60),
|
|
(UNIT_FLAGS, 0x0004_0000 | 0x0010_0000), // stunned + taxi
|
|
]);
|
|
let out = findings(&f, ShieldReport::Armed);
|
|
assert_eq!(out.len(), 1);
|
|
assert!(out[0].contains("STUNNED") && out[0].contains("TAXI FLIGHT"));
|
|
}
|
|
}
|