diff --git a/docs/verification/watched-reputation-faction-research.md b/docs/verification/watched-reputation-faction-research.md new file mode 100644 index 000000000..819aebc4c --- /dev/null +++ b/docs/verification/watched-reputation-faction-research.md @@ -0,0 +1,103 @@ +# Watched reputation faction research + +Research status: complete. The reporter confirmed that the affected display is the watched bar near +the experience bar. Client Verification of the fix is outstanding. This note checks source at +LyraCore commit `46bf948b27b6d21555832b7505ce06ba5fae4f48`, the commit named in +[issue #650](https://github.com/LyraCoreProject/LyraCore/issues/650). It uses CMaNGOS Classic commit +`8ec338a1704e7dcb1c0213eb7ed58f9231ade40f` and the `wow_world_messages` 0.3.0 source commit +`9a1162cb1696518c4c38c794c18e693f0bd68f51`. No live Realm or client was used. + +## Confirmed protocol behavior + +CMaNGOS initializes `PLAYER_FIELD_WATCHED_FACTION_INDEX` to signed `-1`. It restores the field +from the saved Character value and saves its raw 32-bit representation. Its SQL column is unsigned, +so the saved clear value is `4294967295`. The SQL default of zero is not the Character creation +default. [Creation](https://github.com/cmangos/mangos-classic/blob/8ec338a1704e7dcb1c0213eb7ed58f9231ade40f/src/game/Entities/Player.cpp#L804), +[restoration](https://github.com/cmangos/mangos-classic/blob/8ec338a1704e7dcb1c0213eb7ed58f9231ade40f/src/game/Entities/Player.cpp#L14146), +[saving](https://github.com/cmangos/mangos-classic/blob/8ec338a1704e7dcb1c0213eb7ed58f9231ade40f/src/game/Entities/Player.cpp#L15722-L15723), +[SQL column](https://github.com/cmangos/mangos-classic/blob/8ec338a1704e7dcb1c0213eb7ed58f9231ade40f/sql/base/characters.sql#L684). + +`CMSG_SET_WATCHED_FACTION` is opcode `0x0318`. The CMaNGOS handler reads one signed 32-bit +value and puts it into that field. It does not change reputation standing and has no range Gate. +[Opcode](https://github.com/cmangos/mangos-classic/blob/8ec338a1704e7dcb1c0213eb7ed58f9231ade40f/src/game/Server/Opcodes.h#L830), +[handler](https://github.com/cmangos/mangos-classic/blob/8ec338a1704e7dcb1c0213eb7ed58f9231ade40f/src/game/Entities/CharacterHandler.cpp#L1119-L1125). + +The field occupies one 32-bit descriptor at absolute index `0x4ED`, or `1261`. CMaNGOS marks it +private to the owning Character. The bundled update-mask setter accepts `i32` at the same index. +[CMaNGOS field](https://github.com/cmangos/mangos-classic/blob/8ec338a1704e7dcb1c0213eb7ed58f9231ade40f/src/game/Entities/UpdateFields.cpp#L315), +[setter](https://github.com/gtker/wow_messages/blob/9a1162cb1696518c4c38c794c18e693f0bd68f51/wow_world_messages/src/helper/vanilla/update_mask/impls.rs#L1527-L1530). + +The selection names a reputation-list slot. `Faction.dbc` has separate fields for the faction ID +and signed `reputationListID`. CMaNGOS keys its reputation state by `reputationListID` and sends +64 initial slots. This supports a safe selection range of `0..=63`, with `-1` for no selection. +Checking that range is a proposed Gate, not behavior copied from the upstream request handler. +[DBC fields](https://github.com/cmangos/mangos-classic/blob/8ec338a1704e7dcb1c0213eb7ed58f9231ade40f/src/game/Server/DBCStructure.h#L359-L385), +[state keys](https://github.com/cmangos/mangos-classic/blob/8ec338a1704e7dcb1c0213eb7ed58f9231ade40f/src/game/Reputation/ReputationMgr.cpp#L243-L262), +[initial slots](https://github.com/cmangos/mangos-classic/blob/8ec338a1704e7dcb1c0213eb7ed58f9231ade40f/src/game/Reputation/ReputationMgr.cpp#L197-L230). + +The bundled generated request decoder disagrees with CMaNGOS. It expects a two-byte `Faction` +enum. Read exactly four little-endian bytes as `i32` for this opcode. +[Generated decoder](https://github.com/gtker/wow_messages/blob/9a1162cb1696518c4c38c794c18e693f0bd68f51/wow_world_messages/src/world/vanilla/cmsg_set_watched_faction.rs#L11-L29). + +## Confirmed omissions and the reported symptom + +At the inspected LyraCore commit, the Character CREATE builder does not set the watched field. +The opcode path checks for a four-byte body, then returns an unavailable notice. The durable +Character row has no watched selection. Login initializes reputation slots in a separate packet. +[CREATE](https://github.com/LyraCoreProject/LyraCore/blob/46bf948b27b6d21555832b7505ce06ba5fae4f48/gateway/src/codec/entity.rs#L303-L484), +[request path](https://github.com/LyraCoreProject/LyraCore/blob/46bf948b27b6d21555832b7505ce06ba5fae4f48/gateway/src/world/handlers/unavailable.rs#L206-L208), +[Character row](https://github.com/LyraCoreProject/LyraCore/blob/46bf948b27b6d21555832b7505ce06ba5fae4f48/module/src/character.rs), +[initial factions](https://github.com/LyraCoreProject/LyraCore/blob/46bf948b27b6d21555832b7505ce06ba5fae4f48/gateway/src/codec/entity.rs#L730-L757). + +Those omissions establish missing initialization and persistence. The reporter later confirmed +that the Bloodsail Buccaneers display is the watched bar. We have not established that Bloodsail +occupies an omitted field's client default slot, or that standing changed. The original issue +leaves those facts unconfirmed. +[Reported symptom](https://github.com/LyraCoreProject/LyraCore/issues/650). + +## Chosen implementation + +Append `watched_faction_index: i32` to the durable Character row with a default of `-1`. This gives +fresh and preexisting Characters the same no-selection value. Normal Transfers carry the selection +with the whole Character row, which the existing Transfer code serializes and decodes. +[Serialization](https://github.com/LyraCoreProject/LyraCore/blob/46bf948b27b6d21555832b7505ce06ba5fae4f48/module/src/transfer/transport.rs#L625-L649), +[decoding](https://github.com/LyraCoreProject/LyraCore/blob/46bf948b27b6d21555832b7505ce06ba5fae4f48/module/src/transfer/mod.rs#L1492-L1500). + +Old in-flight Escrow contains Character bytes without the appended field. Add a narrow decode +compatibility path for that prior row shape, restoring its watched selection as `-1`. Keep malformed +or otherwise unsupported rows as a Refusal. The baseline decoder reads directly into the current +Character type, so a durable column default alone does not handle those old bytes. +[Baseline decoder](https://github.com/LyraCoreProject/LyraCore/blob/46bf948b27b6d21555832b7505ce06ba5fae4f48/module/src/transfer/mod.rs#L1492-L1500). + +No separate preference table or deletion registration is needed. Character deletion removes the +whole durable row, including the selection. +[Character deletion](https://github.com/LyraCoreProject/LyraCore/blob/46bf948b27b6d21555832b7505ce06ba5fae4f48/module/src/world.rs#L1243-L1245). + +The Gateway should decode the intent and make a Durable Request. The Module should admit `-1` +and `0..=63`, refuse other values without changing the prior selection, and require current Actor +authority and a live Character entity. The live entity disappears before Transfer exports state. +Avoid adding a taxi restriction accidentally, because the general `actor` helper also refuses +actions during flight. Send the restored selection on every self CREATE, including zero and `-1`. +Use an owner-only field update if the durable selection changes during the World Session. +[Actor authority](https://github.com/LyraCoreProject/LyraCore/blob/46bf948b27b6d21555832b7505ce06ba5fae4f48/module/src/account_ownership.rs#L481-L529), +[actor helper](https://github.com/LyraCoreProject/LyraCore/blob/46bf948b27b6d21555832b7505ce06ba5fae4f48/module/src/gw.rs#L152-L160), +[Transfer freeze](https://github.com/LyraCoreProject/LyraCore/blob/46bf948b27b6d21555832b7505ce06ba5fae4f48/module/src/transfer/mod.rs#L1146-L1160). + +## Verification to perform + +Automated checks below exercise protocol and durable state. The graphical client check remains +outstanding. The PR records the completed runs. + +- At the protocol Seam, accept exactly four bytes for `-1`, `0` and `63`. Refuse truncation and + trailing bytes. At the Module boundary, refuse `-2`, `64` and extreme signed values without + changing the selected index or reputation standings. +- Decode self CREATE and owner updates. Assert field `1261` explicitly contains the expected value, + including `FFFFFFFF` for clear and `00000000` for slot zero. Check a fresh Character and a + preexisting Character with no saved selection. +- Select, change and clear a faction. Check each result after login, a same-Shard map change and a + cross-shard Transfer. Check that old Escrow without the appended Character field imports as no + selection, and that malformed rows still cause a Refusal. +- With a real 1.12.1 build-5875 client, check that the unwanted watched bar no longer + appears and that selecting and clearing survives the + lifecycle steps above. Record standing before and after. Keep this symptom outstanding until + that check establishes a connection. diff --git a/gateway/src/codec/entity.rs b/gateway/src/codec/entity.rs index a7ed2776a..06c9a0a15 100644 --- a/gateway/src/codec/entity.rs +++ b/gateway/src/codec/entity.rs @@ -7,11 +7,7 @@ use std::time::{SystemTime, UNIX_EPOCH}; /// A minimal mirror of the entity-row fields the encoder needs. The gateway fills this from /// the `game_world_entity` subscription cache; it is decoupled from the module's table type. /// -/// `zone_id` is the one field not on the `game_world_entity` row — it rides along from the -/// `game_character` row at login so the gateway can address `SMSG_BINDPOINTUPDATE` (the world -/// entity carries only `map_id`/position; deriving a zone from position would need map/area -/// geometry the entity row deliberately doesn't carry, so the gateway rides the value in from -/// login instead). +/// Character fields supply the saved watched faction, hearth point and fallback zone at login. #[derive(Clone, Debug, Default)] pub struct EntityView { pub guid: u64, @@ -22,6 +18,8 @@ pub struct EntityView { /// `player_login` hands back a view, not the row. pub instance_id: u64, pub zone_id: u32, + /// Saved Character selection, sent only to its own client. None clears the watched bar. + pub watched_faction_index: Option, pub x: f32, pub y: f32, pub z: f32, @@ -415,6 +413,10 @@ pub fn build_create_object( builder = builder .set_player_guildid(entity.guild_id as i32) .set_player_guildrank(entity.guild_rank as i32); + if matches!(kind, CreateKind::SelfPlayer) { + builder = builder + .set_player_field_watched_faction_index(entity.watched_faction_index.unwrap_or(-1)); + } if entity.mount_display_id != 0 { builder = builder.set_unit_mountdisplayid(entity.mount_display_id as i32); } diff --git a/gateway/src/codec/tests.rs b/gateway/src/codec/tests.rs index 1c6708ba1..6a7e4cb7a 100644 --- a/gateway/src/codec/tests.rs +++ b/gateway/src/codec/tests.rs @@ -36,6 +36,43 @@ fn warrior_entity() -> EntityView { } } +#[test] +fn fresh_character_create_has_no_watched_faction() { + let message = build_create_object(&warrior_entity(), CreateKind::SelfPlayer, &[], &[]).unwrap(); + let Object::CreateObject2 { + mask2: UpdateMask::Player(fields), + .. + } = &message.objects[0] + else { + panic!("expected Character CREATE"); + }; + assert_eq!(fields.player_field_watched_faction_index(), Some(-1)); +} + +#[test] +fn character_create_restores_the_private_watched_faction() { + for index in [-1, 0, 19, 63] { + let entity = EntityView { + watched_faction_index: Some(index), + ..warrior_entity() + }; + for (kind, expected) in [ + (CreateKind::SelfPlayer, Some(index)), + (CreateKind::Peer, None), + ] { + let message = build_create_object(&entity, kind, &[], &[]).unwrap(); + let Object::CreateObject2 { + mask2: UpdateMask::Player(fields), + .. + } = &message.objects[0] + else { + panic!("expected Character CREATE"); + }; + assert_eq!(fields.player_field_watched_faction_index(), expected); + } + } +} + /// Reputation relay: a known faction id maps to its `Faction` enum value and the signed standing /// survives the wire `u32` bit-cast (the client reads it back as `i32`); an unknown faction id yields /// `None` so the relay skips silently instead of sending garbage. diff --git a/gateway/src/codec/values.rs b/gateway/src/codec/values.rs index a29210c1f..fbb435b61 100644 --- a/gateway/src/codec/values.rs +++ b/gateway/src/codec/values.rs @@ -32,6 +32,13 @@ fn player_values(guid: u64, apply: impl FnOnce(&mut UpdatePlayer)) -> SMSG_UPDAT } } +/// Update the Character's private watched reputation selection. -1 clears the bar. +pub fn build_watched_faction_values(guid: u64, reputation_index: i32) -> SMSG_UPDATE_OBJECT { + player_values(guid, |player| { + player.set_player_field_watched_faction_index(reputation_index); + }) +} + /// Build a VALUES partial-update (`Object::Values`) carrying only `UNIT_FIELD_HEALTH` (Tier 3 /// combat). Unlike `build_create_object`, this pushes a *single changed field* to observers via a /// sparse update mask — no re-create, no flicker. The foundation for combat damage (and any future diff --git a/gateway/src/stdb/bindings/character_type.rs b/gateway/src/stdb/bindings/character_type.rs index 1106c52b4..7b5a910d9 100644 --- a/gateway/src/stdb/bindings/character_type.rs +++ b/gateway/src/stdb/bindings/character_type.rs @@ -52,6 +52,7 @@ pub struct Character { pub pending_godmode: bool, pub pending_run_speed_mult_bp: u32, pub bank_bag_slots: u8, + pub watched_faction_index: i32, } impl __sdk::InModule for Character { @@ -107,6 +108,7 @@ pub struct CharacterCols { pub pending_godmode: __sdk::__query_builder::Col, pub pending_run_speed_mult_bp: __sdk::__query_builder::Col, pub bank_bag_slots: __sdk::__query_builder::Col, + pub watched_faction_index: __sdk::__query_builder::Col, } impl __sdk::__query_builder::HasCols for Character { @@ -173,6 +175,10 @@ impl __sdk::__query_builder::HasCols for Character { "pending_run_speed_mult_bp", ), bank_bag_slots: __sdk::__query_builder::Col::new(table_name, "bank_bag_slots"), + watched_faction_index: __sdk::__query_builder::Col::new( + table_name, + "watched_faction_index", + ), } } } diff --git a/gateway/src/stdb/bindings/gw_set_watched_faction_reducer.rs b/gateway/src/stdb/bindings/gw_set_watched_faction_reducer.rs new file mode 100644 index 000000000..9c6a9ff3d --- /dev/null +++ b/gateway/src/stdb/bindings/gw_set_watched_faction_reducer.rs @@ -0,0 +1,83 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::session_actor_type::SessionActor; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub(super) struct GwSetWatchedFactionArgs { + pub request_actor: SessionActor, + pub reputation_index: i32, +} + +impl From for super::Reducer { + fn from(args: GwSetWatchedFactionArgs) -> Self { + Self::GwSetWatchedFaction { + request_actor: args.request_actor, + reputation_index: args.reputation_index, + } + } +} + +impl __sdk::InModule for GwSetWatchedFactionArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the reducer `gw_set_watched_faction`. +/// +/// Implemented for [`super::RemoteReducers`]. +pub trait gw_set_watched_faction { + /// Request that the remote module invoke the reducer `gw_set_watched_faction` to run as soon as possible. + /// + /// This method returns immediately, and errors only if we are unable to send the request. + /// The reducer will run asynchronously in the future, + /// and this method provides no way to listen for its completion status. + /// /// Use [`gw_set_watched_faction:gw_set_watched_faction_then`] to run a callback after the reducer completes. + fn gw_set_watched_faction( + &self, + request_actor: SessionActor, + reputation_index: i32, + ) -> __sdk::Result<()> { + self.gw_set_watched_faction_then(request_actor, reputation_index, |_, _| {}) + } + + /// Request that the remote module invoke the reducer `gw_set_watched_faction` to run as soon as possible, + /// registering `callback` to run when we are notified that the reducer completed. + /// + /// This method returns immediately, and errors only if we are unable to send the request. + /// The reducer will run asynchronously in the future, + /// and its status can be observed with the `callback`. + fn gw_set_watched_faction_then( + &self, + request_actor: SessionActor, + reputation_index: i32, + + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()>; +} + +impl gw_set_watched_faction for super::RemoteReducers { + fn gw_set_watched_faction_then( + &self, + request_actor: SessionActor, + reputation_index: i32, + + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()> { + self.imp.invoke_reducer_with_callback( + GwSetWatchedFactionArgs { + request_actor, + reputation_index, + }, + callback, + ) + } +} diff --git a/gateway/src/stdb/bindings/mod.rs b/gateway/src/stdb/bindings/mod.rs index 5b78847ef..ea3d9d6a1 100644 --- a/gateway/src/stdb/bindings/mod.rs +++ b/gateway/src/stdb/bindings/mod.rs @@ -827,6 +827,7 @@ pub mod gw_set_sheathed_reducer; pub mod gw_set_target_reducer; pub mod gw_set_trade_gold_reducer; pub mod gw_set_trade_item_reducer; +pub mod gw_set_watched_faction_reducer; pub mod gw_skin_reducer; pub mod gw_spirit_res_reducer; pub mod gw_split_item_reducer; @@ -2009,6 +2010,7 @@ pub use gw_set_sheathed_reducer::gw_set_sheathed; pub use gw_set_target_reducer::gw_set_target; pub use gw_set_trade_gold_reducer::gw_set_trade_gold; pub use gw_set_trade_item_reducer::gw_set_trade_item; +pub use gw_set_watched_faction_reducer::gw_set_watched_faction; pub use gw_skin_reducer::gw_skin; pub use gw_spirit_res_reducer::gw_spirit_res; pub use gw_split_item_reducer::gw_split_item; @@ -3837,6 +3839,10 @@ pub enum Reducer { trade_slot: u8, inv_slot: u8, }, + GwSetWatchedFaction { + request_actor: SessionActor, + reputation_index: i32, + }, GwSkin { request_actor: SessionActor, corpse_guid: u64, @@ -4814,6 +4820,7 @@ impl __sdk::Reducer for Reducer { Reducer::GwSetTarget { .. } => "gw_set_target", Reducer::GwSetTradeGold { .. } => "gw_set_trade_gold", Reducer::GwSetTradeItem { .. } => "gw_set_trade_item", + Reducer::GwSetWatchedFaction { .. } => "gw_set_watched_faction", Reducer::GwSkin { .. } => "gw_skin", Reducer::GwSpiritRes { .. } => "gw_spirit_res", Reducer::GwSplitItem { .. } => "gw_split_item", @@ -7573,6 +7580,13 @@ Reducer::GwIgnoreTrade{ request_actor: request_actor.clone(), trade_slot: trade_slot.clone(), inv_slot: inv_slot.clone(), +}), + Reducer::GwSetWatchedFaction{ + request_actor, + reputation_index, +} => __sats::bsatn::to_vec(&gw_set_watched_faction_reducer::GwSetWatchedFactionArgs { + request_actor: request_actor.clone(), + reputation_index: reputation_index.clone(), }), Reducer::GwSkin{ request_actor, diff --git a/gateway/src/stdb/store/session.rs b/gateway/src/stdb/store/session.rs index 246e6dd9e..f485926dd 100644 --- a/gateway/src/stdb/store/session.rs +++ b/gateway/src/stdb/store/session.rs @@ -50,20 +50,6 @@ impl SessionStore for Coordinator { // briefly until it appears (home_* ride along from the game_character row, and its // zone_id is the fallback for a live row the Module could not resolve a zone for). let character_guid = character.guid(); - let char_row = self - .0 - .coord() - .conn - .db - .game_character() - .guid() - .find(&character_guid); - let zone_id = char_row.as_ref().map(|c| c.zone_id).unwrap_or(0); - let home_map = char_row.as_ref().map(|c| c.home_map).unwrap_or(0); - let home_zone = char_row.as_ref().map(|c| c.home_zone).unwrap_or(0); - let home_x = char_row.as_ref().map(|c| c.home_x).unwrap_or(0.0); - let home_y = char_row.as_ref().map(|c| c.home_y).unwrap_or(0.0); - let home_z = char_row.as_ref().map(|c| c.home_z).unwrap_or(0.0); // 15 s cap, 15 ms steps. Was 3 s — the cold-1000 measurement showed the reducer // COMMITTING while the coordinator stream lagged the login-burst tail past 3 s (writer at // 34.5%, so pure propagation, not CPU): 67/1000 logins died here with the entity already @@ -78,18 +64,31 @@ impl SessionStore for Coordinator { .guid() .find(&character_guid) { - let mut view = entity_view(e, zone_id); - view.home_map = home_map; - view.home_zone = home_zone; - view.home_x = home_x; - view.home_y = home_y; - view.home_z = home_z; + let Some(character) = self + .0 + .coord() + .conn + .db + .game_character() + .guid() + .find(&character_guid) + else { + std::thread::sleep(Duration::from_millis(15)); + continue; + }; + let mut view = entity_view(e, character.zone_id); + view.home_map = character.home_map; + view.home_zone = character.home_zone; + view.home_x = character.home_x; + view.home_y = character.home_y; + view.home_z = character.home_z; + view.watched_faction_index = Some(character.watched_faction_index); return Ok(view); } std::thread::sleep(Duration::from_millis(15)); } Err(anyhow!( - "player_login committed but game_world_entity {character_guid} not visible in the \ + "player_login committed but Character or game_world_entity {character_guid} not visible in the \ coordinator cache within 15s" )) } diff --git a/gateway/src/stdb/store/trainer.rs b/gateway/src/stdb/store/trainer.rs index ac836e124..9062e791d 100644 --- a/gateway/src/stdb/store/trainer.rs +++ b/gateway/src/stdb/store/trainer.rs @@ -374,6 +374,32 @@ impl TrainerStore for Coordinator { gw_set_faction_at_war_then(self.session_actor(actor), reputation_index, at_war) )) } + + fn set_watched_faction( + &self, + actor: Actor, + reputation_index: i32, + ) -> Result { + let coord = self.0.call_pipe(); + let outcome = interaction_outcome(call_reducer!( + coord.conn.reducers, + "gw_set_watched_faction", + gw_set_watched_faction_then(self.session_actor(actor), reputation_index) + ))?; + if matches!(outcome, InteractionOutcome::Done) { + super::wait_for_cache_row(actor.guid(), "watched faction", || { + self.0 + .coord() + .conn + .db + .game_character() + .guid() + .find(&actor.guid()) + .filter(|character| character.watched_faction_index == reputation_index) + })?; + } + Ok(outcome) + } } /// Mirror of the module's `talent::pick_rank_spell`: rank N's spell from the per-rank columns diff --git a/gateway/src/stdb/views.rs b/gateway/src/stdb/views.rs index 862189098..31e996638 100644 --- a/gateway/src/stdb/views.rs +++ b/gateway/src/stdb/views.rs @@ -119,6 +119,7 @@ pub(crate) fn entity_view(e: WorldEntity, durable_zone: u32) -> crate::codec::En guid: e.guid, map_id: e.map_id, instance_id: e.instance_id, + watched_faction_index: None, zone_id: if e.zone_id != 0 { e.zone_id } else { diff --git a/gateway/src/stdb/world_view.rs b/gateway/src/stdb/world_view.rs index 0974bd4ec..070b0d643 100644 --- a/gateway/src/stdb/world_view.rs +++ b/gateway/src/stdb/world_view.rs @@ -5744,6 +5744,7 @@ mod account_claim_relay_tests { pending_godmode: false, pending_run_speed_mult_bp: 0, bank_bag_slots: 0, + watched_faction_index: -1, } } diff --git a/gateway/src/world/benilla_tests.rs b/gateway/src/world/benilla_tests.rs index cb9bbbf25..ebddb754b 100644 --- a/gateway/src/world/benilla_tests.rs +++ b/gateway/src/world/benilla_tests.rs @@ -1113,10 +1113,6 @@ fn benilla_missing_gameplay_returns_a_specific_refusal_without_disconnect() { opcode::CMSG_SET_FACTION_INACTIVE, messages::set_faction_inactive(2, true), ), - ( - opcode::CMSG_SET_WATCHED_FACTION, - messages::set_watched_faction(-1), - ), (opcode::CMSG_FAR_SIGHT, vec![1]), (opcode::CMSG_SUMMON_RESPONSE, messages::summon_response(99)), ( @@ -1368,3 +1364,79 @@ fn benilla_compatibility_requests_refuse_truncation_and_trailing_bytes() { } } } + +#[test] +fn benilla_watched_faction_selects_and_clears_only_the_private_field() { + let store = gameplay_store(GameplayState::default()); + let (mut client, gateway) = client_in_world(store.clone()); + assert_eq!(client.objects[&1].get(&1261), Some(&u32::MAX)); + for index in [0i32, 19, 63, -1] { + client.send( + opcode::CMSG_SET_WATCHED_FACTION, + &messages::set_watched_faction(index), + ); + let ServerPacket::UpdateObject { objects } = client.recv() else { + panic!("expected watched faction update"); + }; + let [Object::Values { guid, mask }] = objects.as_slice() else { + panic!("expected one VALUES block"); + }; + assert_eq!(*guid, 1); + assert_eq!( + mask.raw_fields().collect::>(), + vec![(1261, index as u32)] + ); + assert_eq!( + store.trainer.watched_factions.lock().unwrap().get(&1), + Some(&index) + ); + } + client.logout(); + drop(client); + gateway.join().unwrap().unwrap(); +} + +#[test] +fn benilla_watched_faction_refusal_preserves_the_selection() { + let store = gameplay_store(GameplayState::default()); + let (mut client, gateway) = client_in_world(store.clone()); + client.send( + opcode::CMSG_SET_WATCHED_FACTION, + &messages::set_watched_faction(19), + ); + assert!(matches!(client.recv(), ServerPacket::UpdateObject { .. })); + for index in [-2, 64, i32::MAX, i32::MIN] { + client.send( + opcode::CMSG_SET_WATCHED_FACTION, + &messages::set_watched_faction(index), + ); + assert!(matches!(client.recv(), ServerPacket::MessageChat(_))); + assert_eq!(client.objects[&1].get(&1261), Some(&19)); + assert_eq!( + store.trainer.watched_factions.lock().unwrap().get(&1), + Some(&19) + ); + } + client.query_clock(); + client.logout(); + drop(client); + gateway.join().unwrap().unwrap(); +} + +#[test] +fn benilla_watched_faction_transport_loss_ends_the_world_session_without_an_update() { + let mut store = gameplay_store(GameplayState::default()); + Arc::get_mut(&mut store) + .unwrap() + .trainer + .watched_faction_transport_lost = true; + let (mut client, gateway) = client_in_world(store.clone()); + client.send( + opcode::CMSG_SET_WATCHED_FACTION, + &messages::set_watched_faction(19), + ); + assert_eq!(client.socket.read(&mut [0; 1]).unwrap(), 0); + assert!(store.trainer.watched_factions.lock().unwrap().is_empty()); + drop(client); + assert!(gateway.join().unwrap().is_err()); +} diff --git a/gateway/src/world/benilla_tests/durable.rs b/gateway/src/world/benilla_tests/durable.rs index d3e631dee..1bb657312 100644 --- a/gateway/src/world/benilla_tests/durable.rs +++ b/gateway/src/world/benilla_tests/durable.rs @@ -827,3 +827,41 @@ fn benilla_durable_binding_refreshes_the_hearth_point_immediately() { client.finish(); gateway.join().unwrap().unwrap(); } + +#[test] +#[ignore = "starts a private SpacetimeDB node"] +fn benilla_durable_watched_faction_survives_reconnect() { + let realm = Realm::start("benilla-watched-faction"); + let (mut client, gateway) = realm.connect("TEST"); + client.enter_world(); + client.query_clock(); + assert_eq!(client.objects[&1].get(&1261), Some(&u32::MAX)); + client.logout(); + drop(client); + gateway.join().unwrap().unwrap(); + + for (before, selected) in [(-1i32, 0i32), (0, 19), (19, -1), (-1, -1)] { + let (mut client, gateway) = realm.connect("TEST"); + client.enter_world(); + client.query_clock(); + assert_eq!(client.objects[&1].get(&1261), Some(&(before as u32))); + client.send( + opcode::CMSG_SET_WATCHED_FACTION, + &messages::set_watched_faction(selected), + ); + client.until_view("saved watched faction", |client| { + client.objects[&1].get(&1261) == Some(&(selected as u32)) + }); + client.query_clock(); + assert_eq!( + realm + .standalone + .query_rows("SELECT watched_faction_index FROM game_character WHERE guid = 1")[0] + ["watched_faction_index"], + selected.to_string() + ); + client.logout(); + drop(client); + gateway.join().unwrap().unwrap(); + } +} diff --git a/gateway/src/world/handlers/mod.rs b/gateway/src/world/handlers/mod.rs index 0729dd58d..239c8181f 100644 --- a/gateway/src/world/handlers/mod.rs +++ b/gateway/src/world/handlers/mod.rs @@ -105,7 +105,9 @@ pub(crate) use taxi::{ TaxiActionStore, }; pub(crate) use trade::{handle_trade, TradeStore}; -pub(crate) use trainer::{handle_at_war, handle_trainer, TrainerBuyOutcome, TrainerStore}; +pub(crate) use trainer::{ + handle_at_war, handle_trainer, handle_watched_faction, TrainerBuyOutcome, TrainerStore, +}; #[cfg(test)] pub(crate) use vendor::tests::InMemoryVendorActions; pub(crate) use vendor::{ diff --git a/gateway/src/world/handlers/trainer.rs b/gateway/src/world/handlers/trainer.rs index ec3aa8e4c..f8f754408 100644 --- a/gateway/src/world/handlers/trainer.rs +++ b/gateway/src/world/handlers/trainer.rs @@ -8,6 +8,13 @@ use wow_world_messages::vanilla::TrainingFailureReason; /// Trainer windows, talents, and the action-bar and reputation settings. pub(crate) trait TrainerStore: Send + Sync { + /// Save a reputation-list index, or -1 to clear the watched bar. + fn set_watched_faction( + &self, + actor: Actor, + reputation_index: i32, + ) -> Result; + /// Does this trainer serve `player_guid`'s class? Gates the window and the "train" gossip /// option through the same predicate the module buys with. Fail-open on missing data. fn trainer_serves(&self, player_guid: u64, trainer_guid: u64) -> Result; @@ -395,3 +402,28 @@ pub(crate) fn handle_at_war( } Ok(()) } + +pub(crate) fn handle_watched_faction( + tx: &SessionTx, + store: &St, + conn: &mut WorldConn, + body: &[u8], +) -> Result<()> { + anyhow::ensure!(body.len() == 4, "invalid CMSG_SET_WATCHED_FACTION body"); + let Some(actor) = social::self_guid(conn).and_then(Actor::new) else { + return Ok(()); + }; + let index = i32::from_le_bytes(body.try_into()?); + if let Some((opcode, info)) = conn.move_coalesce.flush_now() { + forward_movement(store, conn, opcode, &info)?; + } + let message = match store.set_watched_faction(actor, index)? { + InteractionOutcome::Done => ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new( + codec::build_watched_faction_values(actor.guid(), index), + )), + InteractionOutcome::Refused(reason) => { + ServerOpcodeMessage::SMSG_MESSAGECHAT(Box::new(codec::build_gm_system_message(reason))) + } + }; + send(tx, Outbound::One(message)) +} diff --git a/gateway/src/world/handlers/unavailable.rs b/gateway/src/world/handlers/unavailable.rs index c243b5b41..741a8fe35 100644 --- a/gateway/src/world/handlers/unavailable.rs +++ b/gateway/src/world/handlers/unavailable.rs @@ -203,10 +203,6 @@ pub(crate) fn raw_unavailable_outbound(opcode: u32, body: &[u8]) -> Result { - anyhow::ensure!(body.len() == 4, "invalid CMSG_SET_WATCHED_FACTION body"); - Ok(Some(vec![unavailable("Reputation display preferences")])) - } 0x005a => { anyhow::ensure!(body.len() == 12, "invalid CMSG_PAGE_TEXT_QUERY body"); Ok(Some(vec![page_unavailable(u32::from_le_bytes( diff --git a/gateway/src/world/mod.rs b/gateway/src/world/mod.rs index 47b053b94..fa3cb7f2c 100644 --- a/gateway/src/world/mod.rs +++ b/gateway/src/world/mod.rs @@ -1146,6 +1146,11 @@ fn run_world_session_with_queue_and_deadline( handlers::handle_at_war(&tx, &*store, &mut conn, &body)?; continue; } + if hdr.opcode == 0x0318 { + let store = conn.store.current(); + handlers::handle_watched_faction(&tx, &*store, &mut conn, &body)?; + continue; + } if let Some(replies) = handlers::raw_unavailable_outbound(hdr.opcode, &body)? { if matches!(conn.state, WorldState::InWorld(_)) { for reply in replies { diff --git a/gateway/src/world/test_support/entry.rs b/gateway/src/world/test_support/entry.rs index 3da1f205f..2fc5fbc0f 100644 --- a/gateway/src/world/test_support/entry.rs +++ b/gateway/src/world/test_support/entry.rs @@ -142,6 +142,7 @@ pub(crate) fn tester_store(account_id: u64) -> WorldFake { /// `player_login`. pub(crate) fn warrior_entity() -> codec::EntityView { codec::EntityView { + watched_faction_index: None, guid: 1, map_id: 0, instance_id: 0, // the open world diff --git a/gateway/src/world/test_support/world_fake/trainer.rs b/gateway/src/world/test_support/world_fake/trainer.rs index ef8abdb1f..cded7ec7e 100644 --- a/gateway/src/world/test_support/world_fake/trainer.rs +++ b/gateway/src/world/test_support/world_fake/trainer.rs @@ -3,6 +3,8 @@ use super::super::*; #[derive(Default)] pub(crate) struct TrainerState { pub(crate) spells: Vec, + pub(crate) watched_factions: std::sync::Mutex>, + pub(crate) watched_faction_transport_lost: bool, pub(crate) reputation_at_war: std::sync::Mutex>, pub(crate) talent_reset_quote: Option, pub(crate) reset_talents_refusal: Option, @@ -15,6 +17,29 @@ pub(crate) struct TrainerState { } impl TrainerStore for WorldFake { + fn set_watched_faction( + &self, + actor: Actor, + reputation_index: i32, + ) -> Result { + if self.trainer.watched_faction_transport_lost { + return Err( + crate::stdb::ReducerCallError::transport_lost("gw_set_watched_faction").into(), + ); + } + if !(-1..=63).contains(&reputation_index) { + return Ok(InteractionOutcome::Refused( + "invalid watched faction index".into(), + )); + } + self.trainer + .watched_factions + .lock() + .unwrap() + .insert(actor.guid(), reputation_index); + Ok(InteractionOutcome::Done) + } + fn talent_reset_cost(&self, _character_guid: u64) -> Option { self.trainer.talent_reset_quote } diff --git a/gateway/src/world/trainer_tests.rs b/gateway/src/world/trainer_tests.rs index f08c52bcf..a73c66a09 100644 --- a/gateway/src/world/trainer_tests.rs +++ b/gateway/src/world/trainer_tests.rs @@ -107,6 +107,14 @@ impl CharacterStore for TrainerFake { } impl TrainerStore for TrainerFake { + fn set_watched_faction( + &self, + _actor: Actor, + _reputation_index: i32, + ) -> Result { + unimplemented!("set_watched_faction") + } + fn trainer_serves(&self, _player_guid: u64, _trainer_guid: u64) -> Result { Ok(!self.refuses_class) } diff --git a/gateway/tests/schema_parity.rs b/gateway/tests/schema_parity.rs index 5eb3466d5..6994be4bf 100644 --- a/gateway/tests/schema_parity.rs +++ b/gateway/tests/schema_parity.rs @@ -737,7 +737,7 @@ parity_test!(parity_game_character, "game_character", lyracore_module::Character first_login, online, money, rested_xp, last_logout_micros, home_map, home_zone, home_x, home_y, home_z, played_total_secs, session_start_micros, health, power, respec_count, death_expire_micros, pending_instance_id, gm_level, pending_ghost, resting, rested_since_micros, - pending_godmode, pending_run_speed_mult_bp, bank_bag_slots, + pending_godmode, pending_run_speed_mult_bp, bank_bag_slots, watched_faction_index, }); parity_test!(parity_game_world_entity, "game_world_entity", lyracore_module::WorldEntity, bindings::world_entity_type::WorldEntity, { guid, owner_identity, account_id, map_id, x, y, z, orientation, grid_x, grid_y, diff --git a/module/src/auth.rs b/module/src/auth.rs index 12bf76f77..1a4edadc7 100644 --- a/module/src/auth.rs +++ b/module/src/auth.rs @@ -1033,6 +1033,7 @@ pub(crate) fn insert_new_character( pending_godmode: false, // 289: GM playtest carry — off until `.god` + a map change pending_run_speed_mult_bp: crate::world::RUN_SPEED_BP_1X, // 289: 1× until `.speed` + a map change bank_bag_slots: 0, // none bought yet + watched_faction_index: -1, }; // `try_insert` rather than `insert`: if a same-name character was created between the find() diff --git a/module/src/character.rs b/module/src/character.rs index 3599a6d33..bf25bec6f 100644 --- a/module/src/character.rs +++ b/module/src/character.rs @@ -3,6 +3,9 @@ use spacetimedb::{table, Identity}; +// A named constant avoids the table macro's unary-expression precedence in default serialization. +const NO_WATCHED_FACTION: i32 = -1; + /// Durable character; exists whether or not online. [entity] #[table(accessor = game_character, public, index(accessor = by_account, btree(columns = [account_id])))] pub struct Character { @@ -153,4 +156,7 @@ pub struct Character { /// Gateway-subscribed (`game_character`) → hand-synced in `character_type.rs` + `schema_parity.rs`. #[default(0)] pub bank_bag_slots: u8, + /// The watched reputation slot, or -1 when the Character has no selection. + #[default(NO_WATCHED_FACTION)] + pub watched_faction_index: i32, } diff --git a/module/src/gw.rs b/module/src/gw.rs index a5cae79a1..802d8666e 100644 --- a/module/src/gw.rs +++ b/module/src/gw.rs @@ -1462,6 +1462,19 @@ pub fn gw_set_faction_at_war( crate::reputation::apply_set_faction_at_war(ctx, player, reputation_index, at_war) } +/// Save the Actor's watched reputation slot, or clear it with -1. +#[reducer] +pub fn gw_set_watched_faction( + ctx: &ReducerContext, + request_actor: crate::SessionActor, + reputation_index: i32, +) -> Result<(), String> { + require_operator(ctx)?; + let actor_guid = crate::account_ownership::require_actor(ctx, request_actor)?; + acting_entity_by_guid(ctx, actor_guid).ok_or_else(|| "mover not in world".to_string())?; + crate::reputation::apply_set_watched_faction(ctx, actor_guid, reputation_index) +} + /// [`crate::quest::apply_abandon_quest`] with the abandoner named by guid. #[reducer] pub fn gw_abandon_quest( diff --git a/module/src/reputation.rs b/module/src/reputation.rs index acbc789dd..c84f28ce0 100644 --- a/module/src/reputation.rs +++ b/module/src/reputation.rs @@ -23,7 +23,7 @@ pub struct PlayerReputation { pub owner_identity: Identity, pub faction_id: u32, pub standing: i32, - // END-APPENDED (#[default(0)] → additive auto-migrate; negative defaults aren't accepted by the macro). + // END-APPENDED (#[default(0)] → additive auto-migrate). // The Faction.dbc ReputationListID — the SMALL 0..63 index the 5875 client's SMSG_SET_FACTION_STANDING // addresses in its rep array. The client does NOT key on faction_id: sending faction_id 72 (Stormwind) // instead of its rep-index 19 indexes PAST the 64-slot array → null deref → ERROR crash on the first @@ -268,6 +268,25 @@ pub(crate) fn apply_set_faction_at_war( Ok(()) } +/// Save the watched slot without changing standing, visibility or at-war state. +pub(crate) fn apply_set_watched_faction( + ctx: &ReducerContext, + character_guid: u64, + reputation_index: i32, +) -> Result<(), String> { + if !(-1..=63).contains(&reputation_index) { + return Err(format!( + "invalid watched reputation index {reputation_index}" + )); + } + let characters = ctx.db.game_character(); + let mut character = crate::helpers::character_by_guid(ctx, character_guid) + .ok_or_else(|| "Character not found".to_string())?; + character.watched_faction_index = reputation_index; + characters.guid().update(character); + Ok(()) +} + #[cfg(test)] mod rep_tests { use super::{reputation_discount_pct, reputation_rank}; diff --git a/module/src/seed.rs b/module/src/seed.rs index c6d32c7d8..cfd30eb2c 100644 --- a/module/src/seed.rs +++ b/module/src/seed.rs @@ -218,6 +218,7 @@ fn seed_production_core(ctx: &ReducerContext) { pending_godmode: false, // 289: GM playtest carry — off until `.god` + a map change pending_run_speed_mult_bp: crate::world::RUN_SPEED_BP_1X, // 289: 1× bank_bag_slots: 0, + watched_faction_index: -1, }); // The seeded character goes through the same creation-time kit grant as `create_character` // (rows restamp to the real owner identity at establish_session, like its other owned rows). diff --git a/module/src/transfer/harness.rs b/module/src/transfer/harness.rs index 3f382ab23..690e82e2d 100644 --- a/module/src/transfer/harness.rs +++ b/module/src/transfer/harness.rs @@ -571,6 +571,7 @@ fn fixture_character(guid: u64, name: &str) -> crate::character::Character { pending_run_speed_mult_bp: 30_000, // Distinguishable from the column default so a slot count that fails to travel shows up. bank_bag_slots: 3, + watched_faction_index: -1, } } @@ -731,6 +732,60 @@ fn a_populated_character_crosses_a_database_with_every_row_and_value() { ); } +#[test] +fn watched_faction_selection_and_clear_cross_with_the_character() { + for watched_index in [-1, 0, 19, 63] { + let src = FakeDb::populated(GUID); + src.chars + .borrow_mut() + .get_mut(&GUID) + .unwrap() + .watched_faction_index = watched_index; + let blob = export(&src, GUID, XFER, DEST); + let mut dst = FakeDb::new(); + apply_import_blob(&mut dst, XFER, wire(&blob)).unwrap(); + assert_eq!( + dst.with_char(GUID, |c| c.watched_faction_index), + watched_index + ); + } +} + +#[test] +fn escrow_from_before_the_watched_column_imports_with_no_selection() { + let src = FakeDb::populated(GUID); + let mut blob = export(&src, GUID, XFER, DEST); + // The old Character row ended after bank_bag_slots, before the final four-byte i32. + blob.character_row.truncate(blob.character_row.len() - 4); + let mut dst = FakeDb::new(); + apply_import_blob(&mut dst, XFER, wire(&blob)).unwrap(); + assert_eq!(dst.with_char(GUID, |c| c.watched_faction_index), -1); + assert_eq!(dst.with_char(GUID, |c| c.bank_bag_slots), 3); + assert_eq!(dst.with_char(GUID, |c| c.money), 987_654); + assert!(dst.has_in_row(XFER)); +} + +#[test] +fn partial_watched_columns_and_unknown_trailing_columns_refuse_before_import() { + for partial_bytes in [1, 2, 3, 5] { + let src = FakeDb::populated(GUID); + let mut blob = export(&src, GUID, XFER, DEST); + blob.character_row + .truncate(blob.character_row.len() - partial_bytes); + let mut dst = FakeDb::new(); + assert!(apply_import_blob(&mut dst, XFER, wire(&blob)).is_err()); + assert!(!dst.has_character(GUID)); + assert!(!dst.has_in_row(XFER)); + } + let src = FakeDb::populated(GUID); + let mut blob = export(&src, GUID, XFER, DEST); + blob.character_row.push(0); + let mut dst = FakeDb::new(); + assert!(apply_import_blob(&mut dst, XFER, wire(&blob)).is_err()); + assert!(!dst.has_character(GUID)); + assert!(!dst.has_in_row(XFER)); +} + // ======================================================================================= // 2, export -> import -> export is a fixed point // ======================================================================================= diff --git a/module/src/transfer/mod.rs b/module/src/transfer/mod.rs index a974c9aa1..a60cd012c 100644 --- a/module/src/transfer/mod.rs +++ b/module/src/transfer/mod.rs @@ -1493,10 +1493,7 @@ pub(crate) fn apply_import_blob( // the destination coordinates and given the money the blob carries (which is the escrowed value // PLUS any `defer_money_delta` folded in after the freeze — the residual, replayed here // because this is the hop that reads the blob at the destination). - let mut c: crate::character::Character = - spacetimedb::sats::bsatn::from_slice(&decoded.character_row).map_err(|e| { - format!("transfer {transfer_id}: cannot decode the arriving character row: {e}") - })?; + let mut c = decode_character_row(transfer_id, &decoded.character_row)?; if c.guid != guid { return Err(format!( "transfer {transfer_id}: the arriving character row is guid {} but the blob names {guid}", diff --git a/module/src/transfer/transport.rs b/module/src/transfer/transport.rs index 0d9f8763b..c6bddc043 100644 --- a/module/src/transfer/transport.rs +++ b/module/src/transfer/transport.rs @@ -477,8 +477,8 @@ pub struct ExportBlob { pub dest_z: f32, pub dest_o: f32, /// bsatn of the whole `Character` row. Opaque here on purpose: `import_character_blob` decodes - /// it with the DESTINATION's own `Character` type, so a shard on a different build fails loudly - /// at decode instead of silently dropping the columns it does not know. + /// it with the destination's `Character` type. Legacy rows that predate the watched slot + /// receive -1; unknown trailing columns refuse the import. pub character_row: Vec, /// One entry per manifest table, in `CHARACTER_OWNED_TRANSFERS` order. pub payload: Vec, @@ -552,6 +552,40 @@ pub(crate) fn decode_blob(transfer_id: u64, bytes: &[u8]) -> Result Result { + use spacetimedb::sats::bsatn::{from_reader, DecodeError}; + + let refusal = |reason| { + format!("transfer {transfer_id}: cannot decode the arriving character row: {reason}") + }; + let mut remaining = bytes; + match from_reader::(&mut remaining) { + Ok(character) if remaining.is_empty() => Ok(character), + Ok(_) => Err(refusal("trailing bytes".to_string())), + Err(DecodeError::BufferLength { + for_type: "i32", + expected: 4, + given: 0, + }) if remaining.is_empty() => { + let mut upgraded = bytes.to_vec(); + upgraded.extend_from_slice(&(-1i32).to_le_bytes()); + let mut remaining = upgraded.as_slice(); + let character = from_reader::(&mut remaining) + .map_err(|e| refusal(e.to_string()))?; + if !remaining.is_empty() { + return Err(refusal("trailing bytes".to_string())); + } + Ok(character) + } + Err(e) => Err(refusal(e.to_string())), + } +} + /// Manifest tables that a blob from an older build may lack. Each one joined the manifest after /// Core commit `decd7821` (2026-09-22). Production may run any build from that commit on, and we /// do not pin which, so a blob may lack any subset of these tables. A build that had no such table diff --git a/module/tests/watched_faction.rs b/module/tests/watched_faction.rs new file mode 100644 index 000000000..d9ed6219c --- /dev/null +++ b/module/tests/watched_faction.rs @@ -0,0 +1,208 @@ +//! Watched reputation selection through the durable Gateway Verb. + +mod support; + +use std::process::Output; + +use support::{actor, number, Standalone}; + +const CHARACTER: &str = "1"; + +fn start(name: &str) -> Standalone { + let mut shard = Standalone::start(name); + shard.publish_module(); + shard.assert_call("claim_operator", &[]); + shard.assert_call("install_guid_range", &["0"]); + shard.assert_call("debug_spawn_player_entity", &[CHARACTER]); + shard +} + +fn watched(shard: &Standalone, guid: &str) -> i32 { + number( + &shard.query_rows(&format!( + "SELECT watched_faction_index FROM game_character WHERE guid = {guid}" + ))[0], + "watched_faction_index", + ) +} + +fn select(shard: &Standalone, reputation_index: i32) { + shard.assert_call( + "gw_set_watched_faction", + &[&actor(CHARACTER), &reputation_index.to_string()], + ); +} + +fn assert_refusal(output: Output, reason: &str) { + let text = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!(!output.status.success() && text.contains(reason), "{text}"); +} + +#[test] +#[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] +fn seeded_and_created_characters_start_without_a_watched_faction() { + let shard = start("watched-faction-fresh"); + assert_eq!(watched(&shard, CHARACTER), -1); + shard.assert_call( + "create_character", + &[ + "1", + "\"Freshwatch\"", + "1", + "1", + "0", + "0", + "0", + "0", + "0", + "0", + ], + ); + let guid = + &shard.query_rows("SELECT guid FROM game_character WHERE name = 'Freshwatch'")[0]["guid"]; + assert_eq!(watched(&shard, guid), -1); +} + +#[test] +#[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] +fn selecting_and_clearing_preserve_standing_and_at_war_state() { + let shard = start("watched-faction-select"); + shard.assert_call("debug_seed_scenario_fixtures", &[]); + shard.assert_call("gw_set_faction_at_war", &[&actor(CHARACTER), "60", "true"]); + let standing = + shard.query_rows("SELECT * FROM game_player_reputation WHERE character_guid = 1"); + assert!(!standing.is_empty()); + let factions = shard.query_rows("SELECT * FROM game_faction"); + for reputation_index in [0, 19, 63, -1] { + select(&shard, reputation_index); + assert_eq!(watched(&shard, CHARACTER), reputation_index); + assert_eq!( + shard.query_rows("SELECT * FROM game_player_reputation WHERE character_guid = 1"), + standing + ); + assert_eq!(shard.query_rows("SELECT * FROM game_faction"), factions); + } + select(&shard, 19); + for reputation_index in [-2, 64, i32::MIN, i32::MAX] { + assert_refusal( + shard.call( + "gw_set_watched_faction", + &[&actor(CHARACTER), &reputation_index.to_string()], + ), + "invalid watched reputation index", + ); + assert_eq!(watched(&shard, CHARACTER), 19); + } +} + +#[test] +#[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] +fn watched_selection_requires_operator_current_actor_and_live_character() { + let shard = start("watched-faction-gates"); + select(&shard, 19); + assert_refusal( + shard.call_anonymous("gw_set_watched_faction", &[&actor(CHARACTER), "0"]), + "operator only", + ); + assert_eq!(watched(&shard, CHARACTER), 19); + assert_refusal( + shard.call("gw_set_watched_faction", &[&actor("999999"), "0"]), + "mover not in world", + ); + assert_eq!(watched(&shard, CHARACTER), 19); + shard.assert_call("claim_account", &["1", CHARACTER, "650"]); + assert_refusal( + shard.call("gw_set_watched_faction", &[&actor(CHARACTER), "0"]), + "STALE_WORLD_SESSION", + ); + assert_eq!(watched(&shard, CHARACTER), 19); + let current = + r#"{"guid":1,"ownership":{"some":{"account_id":1,"generation":1,"request_nonce":650}}}"#; + shard.assert_call("gw_set_watched_faction", &[current, "63"]); + shard.assert_call( + "begin_transfer", + &["1", current, "0", "0", "1200", "1200", "50", "0", "false"], + ); + assert_refusal( + shard.call("gw_set_watched_faction", &[current, "0"]), + "mover not in world", + ); + assert_eq!(watched(&shard, CHARACTER), 63); +} + +fn enter_world(shard: &Standalone) { + let key = serde_json::to_string(&vec![7u8; 40]).unwrap(); + shard.assert_call( + "establish_session", + &["1", &key, r#"{"__identity__":"0x1"}"#], + ); + shard.assert_call("gw_heartbeat", &[]); + shard.assert_call("gw_player_login", &["1", &actor(CHARACTER)]); +} + +#[test] +#[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] +fn selection_and_clear_survive_logout_and_login() { + let shard = start("watched-faction-relog"); + enter_world(&shard); + for reputation_index in [19, -1] { + select(&shard, reputation_index); + shard.assert_call("debug_logout_character", &[CHARACTER]); + assert_eq!(watched(&shard, CHARACTER), reputation_index); + shard.assert_call("gw_player_login", &["1", &actor(CHARACTER)]); + assert_eq!(watched(&shard, CHARACTER), reputation_index); + } +} + +#[test] +#[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] +fn selection_and_clear_survive_a_map_change_and_world_port() { + let shard = start("watched-faction-map-change"); + enter_world(&shard); + for (transfer_id, reputation_index, map_id) in [(1, 19, 1), (2, -1, 0)] { + select(&shard, reputation_index); + let transfer_id = transfer_id.to_string(); + shard.assert_call( + "begin_transfer", + &[ + &transfer_id, + &actor(CHARACTER), + &map_id.to_string(), + "0", + "1200", + "1200", + "50", + "0", + "false", + ], + ); + shard.assert_call("import_character", &[&transfer_id]); + shard.assert_call("finish_transfer", &[&transfer_id, &actor(CHARACTER)]); + shard.assert_call("gw_player_world_port", &["1", &actor(CHARACTER)]); + assert_eq!(watched(&shard, CHARACTER), reputation_index); + assert_eq!( + shard.query_rows("SELECT map_id FROM game_world_entity WHERE guid = 1")[0]["map_id"], + map_id.to_string() + ); + } +} + +#[test] +#[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] +fn a_character_can_choose_the_watched_faction_during_a_taxi_flight() { + let shard = start("watched-faction-taxi"); + shard.assert_sql( + "INSERT INTO game_active_taxi_flight \ + (character_guid,path_id,source_node_id,destination_node_id,mount_display_id,fare,\ + current_node_index,started_micros) \ + VALUES (1,5090102,5090100,5090101,1147,25,0,1)", + ); + select(&shard, 19); + assert_eq!(watched(&shard, CHARACTER), 19); + select(&shard, -1); + assert_eq!(watched(&shard, CHARACTER), -1); +}