mirror of
https://github.com/Corfirean/coa-server-manager.git
synced 2026-11-11 03:51:01 +00:00
fix(control): MR2.1 fail-closed world-port resolution and ruleset invariant verification
- Make world_port resolution fallible (Result<u16>) across LocalBackend, HostService, DirectRouteManager, and RelayLink without default fallbacks to 8085. - Enforce strict 4-way invariant: local_id ruleset == RealmAccess.mode == Db.realm() == RealmInfo.ruleset. Malformed or unknown suffixes fail closed. - If Wildcard is selected and world-port resolution fails (or simultaneous mode is disabled), Direct and Relay fail closed with actionable error and zero fallback to CoA. - Fix documentation drift in docs/WILDCARD.md and docs/MULTIRULESET_PORTABLE_AUDIT.md regarding simultaneous execution, distinct ports, and current vs historical baseline.
This commit is contained in:
1 parent
5343020bed
commit
ba143e1ccd
16 files changed
+463
-135
No files matched your search
@@ -64,8 +64,31 @@ impl LocalBackend {
|
||||
}
|
||||
|
||||
fn access(&self, local_id: &str) -> Result<RealmAccess> {
|
||||
find_access(&self.descriptors, &(self.installs)(), local_id)
|
||||
.ok_or_else(|| Error::Invalid("That realm is not known to this Manager.".into()))
|
||||
let access =
|
||||
find_access(&self.descriptors, &(self.installs)(), local_id).ok_or_else(|| {
|
||||
Error::Invalid(format!(
|
||||
"That realm is not known to this Manager: {local_id}"
|
||||
))
|
||||
})?;
|
||||
if let Some((_base, rs)) = local_id.split_once(':') {
|
||||
let expected_mode = match rs {
|
||||
"coa" => crate::realms::Mode::Coa,
|
||||
"wildcard" => crate::realms::Mode::Wildcard,
|
||||
"reborn" => crate::realms::Mode::Reborn,
|
||||
_ => {
|
||||
return Err(Error::Invalid(format!(
|
||||
"Malformed or unknown ruleset suffix in local_id: {rs}"
|
||||
)))
|
||||
}
|
||||
};
|
||||
if access.mode != Some(expected_mode) {
|
||||
return Err(Error::Invalid(format!(
|
||||
"Realm access mode {:?} does not match expected ruleset in local_id {local_id}",
|
||||
access.mode
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(access)
|
||||
}
|
||||
|
||||
fn account(&self, local_id: &str, filter: &str) -> Result<Option<AccountRow>> {
|
||||
@@ -89,6 +112,32 @@ impl RealmBackend for LocalBackend {
|
||||
let access = self.access(local_id)?;
|
||||
let db = access.db()?;
|
||||
let ruleset = realm::ruleset_of(&db).map_err(|e| Error::Invalid(e.to_string()))?;
|
||||
|
||||
// Explicit local-id -> ruleset invariant:
|
||||
// local_id ruleset == RealmAccess.mode == Db.realm() == RealmInfo.ruleset
|
||||
if let Some((_base, rs)) = local_id.split_once(':') {
|
||||
let expected_ruleset: crate::portable::Ruleset = rs
|
||||
.parse()
|
||||
.map_err(|_| Error::Invalid(format!("Unknown ruleset in local_id: {rs}")))?;
|
||||
if ruleset != expected_ruleset {
|
||||
return Err(Error::Invalid(format!(
|
||||
"Ruleset of realm DB ({ruleset:?}) does not match local_id {local_id}"
|
||||
)));
|
||||
}
|
||||
if access.mode.map(|m| m.name()) != Some(expected_ruleset.as_str()) {
|
||||
return Err(Error::Invalid(format!(
|
||||
"RealmAccess mode ({:?}) does not match local_id {local_id}",
|
||||
access.mode
|
||||
)));
|
||||
}
|
||||
if db.realm().name() != expected_ruleset.as_str() {
|
||||
return Err(Error::Invalid(format!(
|
||||
"Db realm ({:?}) does not match local_id {local_id}",
|
||||
db.realm()
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(RealmInfo {
|
||||
automatic: cfg.as_ref().is_none_or(|c| !c.existing_only),
|
||||
route: cfg.and_then(|c| c.route),
|
||||
@@ -256,11 +305,22 @@ impl RealmBackend for LocalBackend {
|
||||
}
|
||||
}
|
||||
let access = self.access(local_id)?;
|
||||
let mode = access.mode.unwrap_or(crate::realms::Mode::Coa);
|
||||
let mode = if let Some(m) = access.mode {
|
||||
m
|
||||
} else if let Some((_base, _rs)) = local_id.split_once(':') {
|
||||
return Err(Error::Invalid(format!(
|
||||
"Unresolved mode for ruleset suffix in {local_id}"
|
||||
)));
|
||||
} else {
|
||||
// Legacy unsuffixed CoA realm ONLY
|
||||
crate::realms::Mode::Coa
|
||||
};
|
||||
if let Some(ref root) = access.root {
|
||||
crate::realms::world_port_for_mode(root, mode)
|
||||
} else {
|
||||
Ok(8085)
|
||||
Err(Error::Invalid(format!(
|
||||
"No server installation root for {local_id} to resolve world port"
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -282,8 +342,8 @@ mod tests {
|
||||
fs::create_dir_all(&desc_dir).unwrap();
|
||||
|
||||
let portable_dir = dir.path().join("portable");
|
||||
let rt = PortableRuntime::start(&portable_dir, Box::new(|| vec![]), None).unwrap();
|
||||
let backend = LocalBackend::new(Arc::new(rt), &settings, &desc_dir, Arc::new(|| vec![]));
|
||||
let rt = PortableRuntime::start(&portable_dir, Box::new(Vec::new), None).unwrap();
|
||||
let backend = LocalBackend::new(Arc::new(rt), &settings, &desc_dir, Arc::new(Vec::new));
|
||||
|
||||
// Unknown realm id must return Err, never Ok(RealmInfo { ruleset: Ruleset::Coa, .. })
|
||||
assert!(backend.info("srv-unknown").is_err());
|
||||
@@ -296,11 +356,7 @@ mod tests {
|
||||
let dir = tempdir().unwrap();
|
||||
let root = dir.path().join("srv");
|
||||
fs::create_dir_all(root.join("Settings")).unwrap();
|
||||
fs::write(
|
||||
root.join("Settings/repack.json"),
|
||||
r#"{"worldPort": 8085}"#,
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(root.join("Settings/repack.json"), r#"{"worldPort": 8085}"#).unwrap();
|
||||
fs::write(
|
||||
root.join("Settings/realm-profile.json"),
|
||||
r#"{"active": "coa", "wildcard_created": true, "simultaneous": true, "secondary_world_port": 8086}"#,
|
||||
@@ -318,7 +374,7 @@ mod tests {
|
||||
|
||||
let desc_dir = dir.path().join("descriptors");
|
||||
let portable_dir = dir.path().join("portable");
|
||||
let rt = PortableRuntime::start(&portable_dir, Box::new(|| vec![]), None).unwrap();
|
||||
let rt = PortableRuntime::start(&portable_dir, Box::new(Vec::new), None).unwrap();
|
||||
let root_clone = root.clone();
|
||||
let backend = LocalBackend::new(
|
||||
Arc::new(rt),
|
||||
@@ -329,5 +385,102 @@ mod tests {
|
||||
|
||||
assert_eq!(backend.world_port("srv-test1:coa").unwrap(), 8085);
|
||||
assert_eq!(backend.world_port("srv-test1:wildcard").unwrap(), 8086);
|
||||
|
||||
// When simultaneous is false and active is CoA:
|
||||
fs::write(
|
||||
root.join("Settings/realm-profile.json"),
|
||||
r#"{"active": "coa", "wildcard_created": true, "simultaneous": false}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// CoA succeeds with 8085
|
||||
assert_eq!(backend.world_port("srv-test1:coa").unwrap(), 8085);
|
||||
// Wildcard must FAIL CLOSED, never return 8085
|
||||
let err_wc_mode =
|
||||
crate::realms::world_port_for_mode(&root, crate::realms::Mode::Wildcard).unwrap_err();
|
||||
assert!(
|
||||
err_wc_mode.to_string().contains("not active"),
|
||||
"Expected not active error, got: {err_wc_mode}"
|
||||
);
|
||||
assert!(backend.world_port("srv-test1:wildcard").is_err());
|
||||
|
||||
// Reborn must FAIL CLOSED
|
||||
let err_reb_mode =
|
||||
crate::realms::world_port_for_mode(&root, crate::realms::Mode::Reborn).unwrap_err();
|
||||
assert!(
|
||||
err_reb_mode.to_string().contains("not active"),
|
||||
"Expected not active error, got: {err_reb_mode}"
|
||||
);
|
||||
assert!(backend.world_port("srv-test1:reborn").is_err());
|
||||
|
||||
// Malformed and unknown suffixes must fail closed
|
||||
assert!(backend.access("srv-test1:unknown").is_err());
|
||||
assert!(backend.access("srv-test1:garbage").is_err());
|
||||
assert!(backend.access("srv-test1:coa:extra").is_err());
|
||||
assert!(backend.world_port("srv-test1:unknown").is_err());
|
||||
assert!(backend.world_port("srv-test1:garbage").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn world_port_lookup_failure_causes_direct_and_relay_to_fail_closed() {
|
||||
let dir = tempdir().unwrap();
|
||||
let root = dir.path().join("srv");
|
||||
fs::create_dir_all(root.join("Settings")).unwrap();
|
||||
fs::write(root.join("Settings/repack.json"), r#"{"worldPort": 8085}"#).unwrap();
|
||||
// Server only has active CoA, simultaneous is off
|
||||
fs::write(
|
||||
root.join("Settings/realm-profile.json"),
|
||||
r#"{"active": "coa", "wildcard_created": false, "simultaneous": false}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let desc_dir = dir.path().join("descriptors");
|
||||
let portable_dir = dir.path().join("portable");
|
||||
let rt = PortableRuntime::start(&portable_dir, Box::new(Vec::new), None).unwrap();
|
||||
let root_clone = root.clone();
|
||||
let backend = Arc::new(LocalBackend::new(
|
||||
Arc::new(rt),
|
||||
root.join("Settings"),
|
||||
&desc_dir,
|
||||
Arc::new(move || vec![("test1".to_string(), root_clone.clone())]),
|
||||
));
|
||||
|
||||
let store = Arc::new(std::sync::Mutex::new(
|
||||
crate::control::store::ControlStore::open_in_memory().unwrap(),
|
||||
));
|
||||
let service = Arc::new(crate::control::service::HostService::new(
|
||||
"srv-test1:wildcard",
|
||||
coa_registry_proto::sign::RealmId::new(),
|
||||
store,
|
||||
backend,
|
||||
));
|
||||
|
||||
// 1. HostService::local_world_port fails closed
|
||||
assert!(service.local_world_port().is_err());
|
||||
|
||||
// 2. Direct route manager cannot find a direct route because world port resolution failed
|
||||
let mut direct_mgr =
|
||||
crate::control::direct_route::DirectRouteManager::start(None, service.clone());
|
||||
assert!(!direct_mgr.is_direct_ready());
|
||||
assert!(service.direct_route().is_none());
|
||||
direct_mgr.shutdown();
|
||||
|
||||
// 3. When relay link has no world port resolved, AllocateRelay refuses
|
||||
let mut session = crate::control::service::Session::default();
|
||||
let player = uuid::Uuid::now_v7();
|
||||
let resp = service.handle(
|
||||
&mut session,
|
||||
&player,
|
||||
"KEY",
|
||||
coa_control_proto::app::Request::AllocateRelay { player_id: player },
|
||||
);
|
||||
assert!(matches!(
|
||||
resp,
|
||||
coa_control_proto::app::Response::Error {
|
||||
code: coa_control_proto::app::AppError::NoAccount
|
||||
| coa_control_proto::app::AppError::RelayUnavailable,
|
||||
..
|
||||
}
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,11 @@ impl DirectIngress {
|
||||
local_auth_port: u16,
|
||||
local_world_port: u16,
|
||||
) -> Result<Self> {
|
||||
let auth_host = if auth_bind_port == 0 { "127.0.0.1" } else { "0.0.0.0" };
|
||||
let auth_host = if auth_bind_port == 0 {
|
||||
"127.0.0.1"
|
||||
} else {
|
||||
"0.0.0.0"
|
||||
};
|
||||
let auth_listener =
|
||||
TcpListener::bind(format!("{auth_host}:{auth_bind_port}")).map_err(|e| {
|
||||
Error::Invalid(format!(
|
||||
@@ -48,7 +52,11 @@ impl DirectIngress {
|
||||
.map_err(|e| Error::Invalid(e.to_string()))?
|
||||
.port();
|
||||
|
||||
let world_host = if world_bind_port == 0 { "127.0.0.1" } else { "0.0.0.0" };
|
||||
let world_host = if world_bind_port == 0 {
|
||||
"127.0.0.1"
|
||||
} else {
|
||||
"0.0.0.0"
|
||||
};
|
||||
let world_listener =
|
||||
TcpListener::bind(format!("{world_host}:{world_bind_port}")).map_err(|e| {
|
||||
Error::Invalid(format!(
|
||||
@@ -116,7 +124,9 @@ impl DirectIngress {
|
||||
std::thread::Builder::new()
|
||||
.name("direct-world-worker".into())
|
||||
.spawn(move || {
|
||||
if let Err(e) = handle_world_client(client_stream, local_world_port) {
|
||||
if let Err(e) =
|
||||
handle_world_client(client_stream, local_world_port)
|
||||
{
|
||||
eprintln!("handle_world_client error: {e}");
|
||||
}
|
||||
})
|
||||
@@ -189,7 +199,9 @@ fn handle_auth_client(
|
||||
}
|
||||
}
|
||||
let mut server = server.ok_or_else(|| {
|
||||
Error::Invalid(format!("Cannot connect to local authserver: {local_auth_port}"))
|
||||
Error::Invalid(format!(
|
||||
"Cannot connect to local authserver: {local_auth_port}"
|
||||
))
|
||||
})?;
|
||||
|
||||
let _ = client.set_read_timeout(Some(Duration::from_millis(500)));
|
||||
@@ -287,7 +299,9 @@ fn handle_world_client(mut client: TcpStream, local_world_port: u16) -> Result<(
|
||||
}
|
||||
}
|
||||
let mut server = server.ok_or_else(|| {
|
||||
Error::Invalid(format!("Cannot connect to local worldserver: {local_world_port}"))
|
||||
Error::Invalid(format!(
|
||||
"Cannot connect to local worldserver: {local_world_port}"
|
||||
))
|
||||
})?;
|
||||
|
||||
let _ = client.set_nonblocking(false);
|
||||
|
||||
@@ -304,10 +304,25 @@ fn attempt_route_discovery(
|
||||
.ok()
|
||||
.and_then(|p| p.parse().ok())
|
||||
.unwrap_or(coa_control_proto::relay::LOCAL_AUTH_PORT);
|
||||
let local_world = std::env::var("COA_OVERRIDE_LOCAL_WORLD_PORT")
|
||||
let local_world = match std::env::var("COA_OVERRIDE_LOCAL_WORLD_PORT")
|
||||
.ok()
|
||||
.and_then(|p| p.parse().ok())
|
||||
.unwrap_or_else(|| service.local_world_port());
|
||||
{
|
||||
Some(p) => p,
|
||||
None => match service.local_world_port() {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
set_status(
|
||||
status,
|
||||
RouteStatus::RelayOnly {
|
||||
reason: format!("Cannot resolve local world port: {e}"),
|
||||
},
|
||||
);
|
||||
service.set_direct_route(None);
|
||||
return;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
let desired_auth_port: u16 = std::env::var("COA_DIRECT_AUTH_PORT")
|
||||
.ok()
|
||||
@@ -316,7 +331,7 @@ fn attempt_route_discovery(
|
||||
let desired_world_port: u16 = std::env::var("COA_DIRECT_WORLD_PORT")
|
||||
.ok()
|
||||
.and_then(|p| p.parse().ok())
|
||||
.unwrap_or(8085);
|
||||
.unwrap_or(local_world);
|
||||
|
||||
let ingress = match DirectIngress::start(
|
||||
desired_auth_port,
|
||||
|
||||
@@ -95,19 +95,27 @@ impl HostControl {
|
||||
store.clone(),
|
||||
backend.clone(),
|
||||
));
|
||||
let relay_link =
|
||||
crate::control::transport::relay_url(&base, "/relay/v1/host").map(
|
||||
|r_url| {
|
||||
let rl = crate::control::relay_link::RelayLink::start_with_ports(
|
||||
r_url,
|
||||
realm_id,
|
||||
key.clone(),
|
||||
Some(service.local_world_port()),
|
||||
);
|
||||
service.set_relay(rl.clone());
|
||||
rl
|
||||
},
|
||||
);
|
||||
let relay_link = match service.local_world_port() {
|
||||
Ok(world_port) => {
|
||||
crate::control::transport::relay_url(&base, "/relay/v1/host").map(
|
||||
|r_url| {
|
||||
let rl =
|
||||
crate::control::relay_link::RelayLink::start_with_ports(
|
||||
r_url,
|
||||
realm_id,
|
||||
key.clone(),
|
||||
Some(world_port),
|
||||
);
|
||||
service.set_relay(rl.clone());
|
||||
rl
|
||||
},
|
||||
)
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Relay link unavailable for {local_id}: cannot resolve world port: {e}");
|
||||
None
|
||||
}
|
||||
};
|
||||
let direct_route =
|
||||
Some(crate::control::direct_route::DirectRouteManager::start(
|
||||
Some(base.clone()),
|
||||
|
||||
@@ -319,9 +319,17 @@ fn session(
|
||||
// Strictly predefined local targets: Auth (3724) or World (8085 or bound realm world port)
|
||||
let local_port = match target {
|
||||
RelayTarget::Auth => target.local_port(),
|
||||
RelayTarget::World => {
|
||||
local_world_port.unwrap_or_else(|| target.local_port())
|
||||
}
|
||||
RelayTarget::World => match local_world_port {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
eprintln!("RelayLink cannot connect to World: local_world_port was not provided (fail-closed)");
|
||||
let _ = ws_out_tx.send(TunnelMsg::ConnectErr {
|
||||
stream_id,
|
||||
error: "World port unresolved".into(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
},
|
||||
};
|
||||
match TcpStream::connect(("127.0.0.1", local_port)) {
|
||||
Ok(tcp) => {
|
||||
|
||||
@@ -120,10 +120,8 @@ pub trait RealmBackend: Send + Sync {
|
||||
local_id: &str,
|
||||
params: RemoteImportParams,
|
||||
) -> Result<RemoteImportOutcome>;
|
||||
/// Local world port for this realm.
|
||||
fn world_port(&self, _local_id: &str) -> Result<u16> {
|
||||
Ok(8085)
|
||||
}
|
||||
/// Local world port for this realm. Must fail closed if the realm port cannot be resolved.
|
||||
fn world_port(&self, local_id: &str) -> Result<u16>;
|
||||
}
|
||||
|
||||
/// State of one channel.
|
||||
@@ -224,8 +222,8 @@ impl HostService {
|
||||
self.realm_id
|
||||
}
|
||||
|
||||
pub fn local_world_port(&self) -> u16 {
|
||||
self.backend.world_port(&self.local_id).unwrap_or(8085)
|
||||
pub fn local_world_port(&self) -> Result<u16> {
|
||||
self.backend.world_port(&self.local_id)
|
||||
}
|
||||
|
||||
/// The player's public key is the one recorded for this player id when there is a record; `None` when the player is new here.
|
||||
@@ -1172,6 +1170,7 @@ pub mod fake {
|
||||
pub seen_passwords: Mutex<Vec<String>>,
|
||||
pub imported: Mutex<HashMap<Uuid, (u32, Uuid)>>,
|
||||
pub incompatible: Mutex<std::collections::HashSet<Uuid>>,
|
||||
pub world_port: Mutex<Option<Result<u16>>>,
|
||||
}
|
||||
|
||||
impl FakeRealm {
|
||||
@@ -1363,6 +1362,16 @@ pub mod fake {
|
||||
notes: vec![],
|
||||
})
|
||||
}
|
||||
fn world_port(&self, _: &str) -> Result<u16> {
|
||||
if let Some(res) = self.world_port.lock().unwrap().as_ref() {
|
||||
match res {
|
||||
Ok(p) => Ok(*p),
|
||||
Err(e) => Err(Error::Invalid(e.to_string())),
|
||||
}
|
||||
} else {
|
||||
Ok(8085)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -201,7 +201,10 @@ impl ControlStore {
|
||||
.map_err(db)?;
|
||||
let _ = conn.pragma_update(None, "journal_mode", "WAL");
|
||||
conn.execute_batch(SCHEMA).map_err(db)?;
|
||||
let _ = conn.execute("ALTER TABLE host_transfer ADD COLUMN ruleset TEXT NOT NULL DEFAULT 'coa'", []);
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE host_transfer ADD COLUMN ruleset TEXT NOT NULL DEFAULT 'coa'",
|
||||
[],
|
||||
);
|
||||
Ok(Self { conn })
|
||||
}
|
||||
|
||||
|
||||
@@ -1652,13 +1652,16 @@ impl PortableService {
|
||||
if !super::super::session::protocol::carried_kind_for(kind, model.ruleset) {
|
||||
return Err(fail(
|
||||
"incompatible",
|
||||
format!("Foreign collection kind {kind:?} in {} remote transfer", model.ruleset),
|
||||
format!(
|
||||
"Foreign collection kind {kind:?} in {} remote transfer",
|
||||
model.ruleset
|
||||
),
|
||||
));
|
||||
}
|
||||
if let Ok(set) = super::super::collection::IdSet::from_ids(ids.iter().copied()) {
|
||||
let _ =
|
||||
self.host
|
||||
.merge_collection(self.host_profile, model.ruleset, kind, &set);
|
||||
let _ = self
|
||||
.host
|
||||
.merge_collection(self.host_profile, model.ruleset, kind, &set);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -812,7 +812,10 @@ fn wildcard_and_coa_collections_remain_strictly_isolated() {
|
||||
set: CompactSet::seal("wildcard:appearance", &wc_update).unwrap(),
|
||||
};
|
||||
let ack = o.handle_collection(&to_json(&msg_wc).unwrap()).unwrap();
|
||||
assert!(matches!(ack.outcome, CollectionOutcome::Applied | CollectionOutcome::Unchanged));
|
||||
assert!(matches!(
|
||||
ack.outcome,
|
||||
CollectionOutcome::Applied | CollectionOutcome::Unchanged
|
||||
));
|
||||
|
||||
// CoA remains exactly [10, 20, 30]
|
||||
let (_, s_coa_after) = o
|
||||
|
||||
@@ -467,7 +467,9 @@ impl RealmBridge for FakeRealm {
|
||||
impl FakeRealm {
|
||||
fn rows(&self, kind: &str) -> Result<&IdSet> {
|
||||
match kind {
|
||||
"coa:appearance" | "wildcard:appearance" | "reborn:appearance" => Ok(&self.appearance_rows),
|
||||
"coa:appearance" | "wildcard:appearance" | "reborn:appearance" => {
|
||||
Ok(&self.appearance_rows)
|
||||
}
|
||||
"coa:vanity" | "wildcard:vanity" | "reborn:vanity" => Ok(&self.vanity_rows),
|
||||
other => Err(PortableError::Invalid(format!("unknown kind {other}"))),
|
||||
}
|
||||
@@ -475,7 +477,9 @@ impl FakeRealm {
|
||||
|
||||
fn rows_mut(&mut self, kind: &str) -> Result<&mut IdSet> {
|
||||
match kind {
|
||||
"coa:appearance" | "wildcard:appearance" | "reborn:appearance" => Ok(&mut self.appearance_rows),
|
||||
"coa:appearance" | "wildcard:appearance" | "reborn:appearance" => {
|
||||
Ok(&mut self.appearance_rows)
|
||||
}
|
||||
"coa:vanity" | "wildcard:vanity" | "reborn:vanity" => Ok(&mut self.vanity_rows),
|
||||
other => Err(PortableError::Invalid(format!("unknown kind {other}"))),
|
||||
}
|
||||
|
||||
@@ -1100,8 +1100,12 @@ fn wildcard_session_offer_carrying_coa_snapshot_is_rejected_with_zero_mutation()
|
||||
let host_profile = host.default_profile().unwrap();
|
||||
|
||||
let coa_model = geared_level_eighty();
|
||||
let id = owner.create_character(profile, coa_model, "realm-a").unwrap();
|
||||
let mut offer = OwnerService::new(&mut owner).offer(id, "srv-1:wildcard").unwrap();
|
||||
let id = owner
|
||||
.create_character(profile, coa_model, "realm-a")
|
||||
.unwrap();
|
||||
let mut offer = OwnerService::new(&mut owner)
|
||||
.offer(id, "srv-1:wildcard")
|
||||
.unwrap();
|
||||
// Tamper offer: claim it is Wildcard while snapshot is CoA
|
||||
offer.ruleset = Ruleset::Wildcard;
|
||||
|
||||
@@ -1199,9 +1203,13 @@ fn wildcard_session_using_coa_server_id_is_rejected() {
|
||||
|
||||
let mut model = geared_level_eighty();
|
||||
model.ruleset = Ruleset::Wildcard;
|
||||
let id = owner.create_character(profile, model, "wildcard-origin").unwrap();
|
||||
let id = owner
|
||||
.create_character(profile, model, "wildcard-origin")
|
||||
.unwrap();
|
||||
// Offer for srv-1:coa
|
||||
let offer = OwnerService::new(&mut owner).offer(id, "srv-1:coa").unwrap();
|
||||
let offer = OwnerService::new(&mut owner)
|
||||
.offer(id, "srv-1:coa")
|
||||
.unwrap();
|
||||
|
||||
// Host configured for Wildcard realm
|
||||
let mut h = HostService::new(&mut host, "srv-1:wildcard", HostConfig::default());
|
||||
@@ -1216,7 +1224,9 @@ fn same_character_id_with_foreign_ruleset_is_rejected() {
|
||||
|
||||
let mut model = geared_level_eighty();
|
||||
model.ruleset = Ruleset::Wildcard;
|
||||
let id = owner.create_character(profile, model.clone(), "srv-1:wildcard").unwrap();
|
||||
let id = owner
|
||||
.create_character(profile, model.clone(), "srv-1:wildcard")
|
||||
.unwrap();
|
||||
|
||||
// Tampered snapshot with same ID but Ruleset::Coa
|
||||
let mut tampered = model.clone();
|
||||
|
||||
@@ -2190,36 +2190,75 @@ fn three_space_isolation_fixture_proves_wildcard_activity_leaves_coa_and_reborn_
|
||||
let mut model_a = fixtures::geared_level_eighty();
|
||||
model_a.identity.name = "Hero".into();
|
||||
model_a.ruleset = Ruleset::Coa;
|
||||
let char_a = store.create_character(profile, model_a, "srv-1:coa").unwrap();
|
||||
let char_a = store
|
||||
.create_character(profile, model_a, "srv-1:coa")
|
||||
.unwrap();
|
||||
|
||||
// Character B: Wildcard
|
||||
let mut model_b = fixtures::geared_level_eighty();
|
||||
model_b.identity.name = "Hero".into();
|
||||
model_b.ruleset = Ruleset::Wildcard;
|
||||
let char_b = store.create_character(profile, model_b, "srv-1:wildcard").unwrap();
|
||||
let char_b = store
|
||||
.create_character(profile, model_b, "srv-1:wildcard")
|
||||
.unwrap();
|
||||
|
||||
// Character C: Reborn
|
||||
let mut model_c = fixtures::geared_level_eighty();
|
||||
model_c.identity.name = "Hero".into();
|
||||
model_c.ruleset = Ruleset::Reborn;
|
||||
let char_c = store.create_character(profile, model_c, "srv-1:reborn").unwrap();
|
||||
let char_c = store
|
||||
.create_character(profile, model_c, "srv-1:reborn")
|
||||
.unwrap();
|
||||
|
||||
// Initial identical mappings (local_guid = 100, item_guid = 200)
|
||||
store.bind_server(char_a, "srv-1:coa", 100, 1, MappingState::Active).unwrap();
|
||||
store.bind_server(char_b, "srv-1:wildcard", 100, 1, MappingState::Active).unwrap();
|
||||
store.bind_server(char_c, "srv-1:reborn", 100, 1, MappingState::Active).unwrap();
|
||||
store
|
||||
.bind_server(char_a, "srv-1:coa", 100, 1, MappingState::Active)
|
||||
.unwrap();
|
||||
store
|
||||
.bind_server(char_b, "srv-1:wildcard", 100, 1, MappingState::Active)
|
||||
.unwrap();
|
||||
store
|
||||
.bind_server(char_c, "srv-1:reborn", 100, 1, MappingState::Active)
|
||||
.unwrap();
|
||||
|
||||
let item_a = PortableItemId::new();
|
||||
let item_b = PortableItemId::new();
|
||||
let item_c = PortableItemId::new();
|
||||
store.reconcile_item_mappings(char_a, "srv-1:coa", 1, &[obs(item_a, 200, 10, "e1")]).unwrap();
|
||||
store.reconcile_item_mappings(char_b, "srv-1:wildcard", 1, &[obs(item_b, 200, 10, "e2")]).unwrap();
|
||||
store.reconcile_item_mappings(char_c, "srv-1:reborn", 1, &[obs(item_c, 200, 10, "e3")]).unwrap();
|
||||
store
|
||||
.reconcile_item_mappings(char_a, "srv-1:coa", 1, &[obs(item_a, 200, 10, "e1")])
|
||||
.unwrap();
|
||||
store
|
||||
.reconcile_item_mappings(char_b, "srv-1:wildcard", 1, &[obs(item_b, 200, 10, "e2")])
|
||||
.unwrap();
|
||||
store
|
||||
.reconcile_item_mappings(char_c, "srv-1:reborn", 1, &[obs(item_c, 200, 10, "e3")])
|
||||
.unwrap();
|
||||
|
||||
// Initial collections
|
||||
store.merge_collection(profile, Ruleset::Coa, "coa:appearance", &IdSet::from_ids([1, 2, 3]).unwrap()).unwrap();
|
||||
store.merge_collection(profile, Ruleset::Wildcard, "wildcard:appearance", &IdSet::from_ids([1, 2, 4]).unwrap()).unwrap();
|
||||
store.merge_collection(profile, Ruleset::Reborn, "reborn:appearance", &IdSet::from_ids([1, 2, 5]).unwrap()).unwrap();
|
||||
store
|
||||
.merge_collection(
|
||||
profile,
|
||||
Ruleset::Coa,
|
||||
"coa:appearance",
|
||||
&IdSet::from_ids([1, 2, 3]).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
store
|
||||
.merge_collection(
|
||||
profile,
|
||||
Ruleset::Wildcard,
|
||||
"wildcard:appearance",
|
||||
&IdSet::from_ids([1, 2, 4]).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
store
|
||||
.merge_collection(
|
||||
profile,
|
||||
Ruleset::Reborn,
|
||||
"reborn:appearance",
|
||||
&IdSet::from_ids([1, 2, 5]).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Record baseline hashes and revisions
|
||||
let hash_a_before = store.load_snapshot(char_a, 1).unwrap();
|
||||
@@ -2229,26 +2268,60 @@ fn three_space_isolation_fixture_proves_wildcard_activity_leaves_coa_and_reborn_
|
||||
let mut model_b_mut = store.load_current(char_b).unwrap();
|
||||
model_b_mut.progression.money += 100_000;
|
||||
model_b_mut.progression.level = 80;
|
||||
store.commit_snapshot(char_b, 1, model_b_mut, "srv-1:wildcard", Some("wildcard gameplay")).unwrap();
|
||||
store
|
||||
.commit_snapshot(
|
||||
char_b,
|
||||
1,
|
||||
model_b_mut,
|
||||
"srv-1:wildcard",
|
||||
Some("wildcard gameplay"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
store.merge_collection(profile, Ruleset::Wildcard, "wildcard:appearance", &IdSet::from_ids([1, 2, 4, 999]).unwrap()).unwrap();
|
||||
store
|
||||
.merge_collection(
|
||||
profile,
|
||||
Ruleset::Wildcard,
|
||||
"wildcard:appearance",
|
||||
&IdSet::from_ids([1, 2, 4, 999]).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let new_item_b = PortableItemId::new();
|
||||
store.reconcile_item_mappings(char_b, "srv-1:wildcard", 2, &[obs(item_b, 200, 10, "e2"), obs(new_item_b, 201, 10, "e2_new")]).unwrap();
|
||||
store
|
||||
.reconcile_item_mappings(
|
||||
char_b,
|
||||
"srv-1:wildcard",
|
||||
2,
|
||||
&[
|
||||
obs(item_b, 200, 10, "e2"),
|
||||
obs(new_item_b, 201, 10, "e2_new"),
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// ---- ASSERT WILDCARD B ADVANCED ----
|
||||
let rec_b = store.character(char_b).unwrap();
|
||||
assert_eq!(rec_b.revision, 2);
|
||||
let (_, coll_b) = store.collection(profile, Ruleset::Wildcard, "wildcard:appearance").unwrap().unwrap();
|
||||
let (_, coll_b) = store
|
||||
.collection(profile, Ruleset::Wildcard, "wildcard:appearance")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(coll_b.ids(), &[1, 2, 4, 999]);
|
||||
assert_eq!(store.item_mappings(char_b, "srv-1:wildcard").unwrap().len(), 2);
|
||||
assert_eq!(
|
||||
store.item_mappings(char_b, "srv-1:wildcard").unwrap().len(),
|
||||
2
|
||||
);
|
||||
|
||||
// ---- ASSERT COA A IS 100% UNTOUCHED ----
|
||||
let rec_a = store.character(char_a).unwrap();
|
||||
assert_eq!(rec_a.revision, 1);
|
||||
let hash_a_after = store.load_snapshot(char_a, 1).unwrap();
|
||||
assert_eq!(hash_a_before, hash_a_after);
|
||||
let (_, coll_a) = store.collection(profile, Ruleset::Coa, "coa:appearance").unwrap().unwrap();
|
||||
let (_, coll_a) = store
|
||||
.collection(profile, Ruleset::Coa, "coa:appearance")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(coll_a.ids(), &[1, 2, 3]);
|
||||
let items_a = store.item_mappings(char_a, "srv-1:coa").unwrap();
|
||||
assert_eq!(items_a.len(), 1);
|
||||
@@ -2260,7 +2333,10 @@ fn three_space_isolation_fixture_proves_wildcard_activity_leaves_coa_and_reborn_
|
||||
assert_eq!(rec_c.revision, 1);
|
||||
let hash_c_after = store.load_snapshot(char_c, 1).unwrap();
|
||||
assert_eq!(hash_c_before, hash_c_after);
|
||||
let (_, coll_c) = store.collection(profile, Ruleset::Reborn, "reborn:appearance").unwrap().unwrap();
|
||||
let (_, coll_c) = store
|
||||
.collection(profile, Ruleset::Reborn, "reborn:appearance")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(coll_c.ids(), &[1, 2, 5]);
|
||||
let items_c = store.item_mappings(char_c, "srv-1:reborn").unwrap();
|
||||
assert_eq!(items_c.len(), 1);
|
||||
|
||||
@@ -130,12 +130,12 @@ pub fn find_access(
|
||||
) -> Option<RealmAccess> {
|
||||
let (base_id, explicit_mode) = if let Some((base, rs)) = local_id.split_once(':') {
|
||||
let m = match rs {
|
||||
"coa" => Some(crate::realms::Mode::Coa),
|
||||
"wildcard" => Some(crate::realms::Mode::Wildcard),
|
||||
"reborn" => Some(crate::realms::Mode::Reborn),
|
||||
_ => None,
|
||||
"coa" => crate::realms::Mode::Coa,
|
||||
"wildcard" => crate::realms::Mode::Wildcard,
|
||||
"reborn" => crate::realms::Mode::Reborn,
|
||||
_ => return None,
|
||||
};
|
||||
(base, m)
|
||||
(base, Some(m))
|
||||
} else {
|
||||
(local_id, None)
|
||||
};
|
||||
@@ -145,6 +145,21 @@ pub fn find_access(
|
||||
{
|
||||
let mut access = RealmAccess::from_install(id, root);
|
||||
if let Some(m) = explicit_mode {
|
||||
if let Ok(st) = crate::realms::state(root) {
|
||||
let supported = match m {
|
||||
crate::realms::Mode::Coa => {
|
||||
st.active == crate::realms::Mode::Coa || st.simultaneous
|
||||
}
|
||||
crate::realms::Mode::Wildcard => {
|
||||
st.active == crate::realms::Mode::Wildcard
|
||||
|| (st.simultaneous && st.wildcard_created)
|
||||
}
|
||||
crate::realms::Mode::Reborn => st.active == crate::realms::Mode::Reborn,
|
||||
};
|
||||
if !supported {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
access.id = format!("srv-{id}:{}", m.name());
|
||||
access.name = format!("{} — {}", access.name, m.label());
|
||||
access.mode = Some(m);
|
||||
|
||||
@@ -749,11 +749,21 @@ pub fn world_port_for_mode(root: &Path, mode: Mode) -> Result<u16> {
|
||||
(Mode::Coa, Mode::Wildcard) => second,
|
||||
(Mode::Wildcard, Mode::Wildcard) => ports.world,
|
||||
(Mode::Wildcard, Mode::Coa) => second,
|
||||
(_, _) => ports.world,
|
||||
_ => {
|
||||
return Err(Error::Invalid(format!(
|
||||
"Unsupported mode {mode:?} for simultaneous hosting (active: {:?})",
|
||||
s.active
|
||||
)))
|
||||
}
|
||||
};
|
||||
Ok(port)
|
||||
} else {
|
||||
} else if mode == s.active {
|
||||
Ok(ports.world)
|
||||
} else {
|
||||
Err(Error::Invalid(format!(
|
||||
"Realm mode {mode:?} is not active (active: {:?}) and simultaneous mode is disabled.",
|
||||
s.active
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -53,9 +53,41 @@ Status: Updated for MR2 Gate (Wildcard Vertical Slice Completed)
|
||||
|
||||
---
|
||||
|
||||
## 2. Inventory of Current Subsystem Locations & Representation
|
||||
## 2. Current Implementation Architecture & Historical Baseline Inventory
|
||||
|
||||
### 2.1 Realm Profiles & Databases (`crates/coa-core/src/realms.rs`, `db.rs`)
|
||||
### 2.1 Current Implementation State (As of MR1, MR1.1, MR2, MR2.1)
|
||||
- **Authoritative Ruleset Representation:**
|
||||
- `crates/coa-core/src/portable/model.rs`: `enum Ruleset { Coa, Wildcard, Reborn }` (strict parsing and schema routing without fallbacks).
|
||||
- `crates/coa-registry-proto/src/caps.rs`: `enum Ruleset { Coa, Wildcard, Reborn }`.
|
||||
- `services/coa-registry/src/pg.rs`: `ruleset: match ruleset.as_str() { "coa" => Ruleset::Coa, "wildcard" => Ruleset::Wildcard, "reborn" => Ruleset::Reborn, ... }`.
|
||||
- `src/lib/browse.ts`: `export type Ruleset = "coa" | "wildcard" | "reborn";`.
|
||||
- `crates/coa-core/src/realms.rs`: `enum Mode { Coa, Wildcard, Reborn }`.
|
||||
- **Wire Protocols & Ruleset Requirements:**
|
||||
- **Control Protocol 2:** `CONTROL_PROTOCOL_VERSION = 2`. `Request::TransferOffer` requires explicit, non-default `ruleset: String`. `Response::Claimed` returns explicit `ruleset: String`. `HostTransfer` records ruleset.
|
||||
- **Session Protocol 3:** `SESSION_PROTOCOL_VERSION = 3`. `SessionOffer`, `PortableSessionStarted`, `PortableCheckpoint`, and `OwnerAck` require explicit, mandatory `ruleset: Ruleset`.
|
||||
- **Collection Protocol 2:** `COLLECTION_PROTOCOL_VERSION = 2`. `CollectionObserved`, `CollectionState`, and `CollectionAck` require explicit, mandatory `ruleset: Ruleset`.
|
||||
- **Database Routing & Fail-Closed Invariants:**
|
||||
- `LocalBackend::info()` and `LocalBackend::access()` strictly enforce:
|
||||
$$\text{local\_id ruleset} == \text{RealmAccess.mode} == \text{Db.realm()} == \text{RealmInfo.ruleset}$$
|
||||
Any mismatch or malformed/unknown ruleset suffix returns an immediate error.
|
||||
- `LocalBackend::world_port()` and `crates/coa-core/src/realms::world_port_for_mode()` return fallible `Result<u16>` without silent fallbacks to 8085. If a realm is not running or not simultaneous, port resolution fails closed and disables Direct and Relay routing for that realm.
|
||||
- **Canonical Storage (`portable.db`):**
|
||||
- Transactional SQLite Migration 009 upgraded `character` table constraint to `CHECK (ruleset IN ('coa', 'wildcard', 'reborn'))` while preserving rows byte-for-byte.
|
||||
- `collection` table is partitioned by `(profile_id, ruleset, kind)`.
|
||||
- Mappings (`character_server_mapping`, `item_mapping`, `pet_mapping`, sessions) isolate distinct server IDs (`srv-X:coa`, `srv-X:wildcard`, `srv-X:reborn`).
|
||||
|
||||
### 2.2 Historical Baseline Inventory (Pre-Implementation State as of v0.7.0 Master)
|
||||
> [!NOTE]
|
||||
> The following findings reflect the legacy v0.7.0 baseline discovered before MR1/MR2 implementation and are preserved for historical reference:
|
||||
- *Historical:* `Ruleset` enum in `model.rs` and `caps.rs` lacked `Reborn`.
|
||||
- *Historical:* `character` table had `CHECK (ruleset IN ('coa', 'wildcard'))`, causing SQLite constraint failures on Reborn.
|
||||
- *Historical:* `collection` table was keyed only by `(profile_id, kind)` without `ruleset`.
|
||||
- *Historical:* `TransferOffer`, `PortableSessionStarted`, `PortableCheckpoint`, and `CollectionState` lacked explicit protocol-level `ruleset` fields.
|
||||
- *Historical:* `LocalBackend::info()` had an unsafe fallback `ruleset_of(db).unwrap_or(Ruleset::Coa)`.
|
||||
- *Historical:* `world_port_for_mode()` fell back to `ports.world` (8085) even when Wildcard was inactive.
|
||||
- *Historical:* `Ruleset::schema(kind)` had `_ => "acore_characters"`.
|
||||
|
||||
### 2.3 Realm Profiles & Databases (`crates/coa-core/src/realms.rs`, `db.rs`)
|
||||
| Ruleset / Mode | Realm ID | Character Database | World Database | Auth Database | Configuration Profile |
|
||||
|---|---|---|---|---|---|
|
||||
| **CoA** | `1` | `acore_characters` | `acore_world` | `acore_auth` | `Settings/realm-profiles/coa.json` |
|
||||
@@ -70,52 +102,11 @@ Status: Updated for MR2 Gate (Wildcard Vertical Slice Completed)
|
||||
- Wildcard detected by byte pattern: `b"Wildcard synergy settings"`.
|
||||
- Reborn detected by byte pattern: `b"Warcraft Reborn class spells are unavailable"`.
|
||||
|
||||
### 2.2 Ruleset Enumeration & Protocols
|
||||
- `crates/coa-core/src/realms.rs`: `enum Mode { Coa, Wildcard, Reborn }`
|
||||
- `crates/coa-core/src/portable/model.rs`: `enum Ruleset { Coa, Wildcard }` *(Lacks `Reborn`)*
|
||||
- `crates/coa-registry-proto/src/caps.rs`: `enum Ruleset { Coa, Wildcard }` *(Lacks `Reborn`)*
|
||||
- `services/coa-registry/src/pg.rs`: `ruleset: match ruleset.as_str() { "coa" => Ruleset::Coa, "wildcard" => Ruleset::Wildcard }` *(Lacks `Reborn`)*
|
||||
- `src/lib/browse.ts`: `export type Ruleset = "coa" | "wildcard";` *(Lacks `reborn`)*
|
||||
|
||||
### 2.3 Public Registry Model & Publication
|
||||
### 2.4 Public Registry Model & Multi-Realm Publication
|
||||
- `crates/coa-core/src/realm_registry/settings.rs`: `RegistrySettings.realms` is a `BTreeMap<String, PublishConfig>`.
|
||||
- `src/screens/PublishCard.tsx`: Uses `localId = 'srv-' + serverId`.
|
||||
- **Host vs Published Realm Identity Issue:**
|
||||
One installation (`InstallMeta.id`) hosting multiple rulesets currently shares `localId = srv-<id>`. If the host switches active realm from CoA to Wildcard, or publishes both under simultaneous mode, `srv-<id>` cannot represent both published realms distinctly.
|
||||
Each published realm must possess its own distinct local realm identity (`srv-<id>-<ruleset>` or `srv-<id>:<ruleset>`), unique `RealmId` (UUIDv7), and independent Ed25519 signing key in `KeyStore`.
|
||||
|
||||
### 2.4 Canonical Portable Storage (`crates/coa-core/src/portable/store.rs`)
|
||||
- SQLite schema (`%LOCALAPPDATA%\CoAServerManager\portable\portable.db`):
|
||||
- `character`: `ruleset TEXT NOT NULL CHECK (ruleset IN ('coa', 'wildcard'))` -> Must be updated to allow `'reborn'`.
|
||||
- `collection`: `PRIMARY KEY (profile_id, kind)` -> Unpartitioned by ruleset! Must become `(profile_id, ruleset, kind)`.
|
||||
- `character_server_mapping`: `PRIMARY KEY (character_id, server_id), UNIQUE (server_id, local_guid)`.
|
||||
- `item_mapping`: `PRIMARY KEY (character_id, server_id, portable_item_id)`.
|
||||
- `pet_mapping`: `PRIMARY KEY (character_id, server_id, portable_pet_id)`.
|
||||
- `owner_session`: `character_id`, `server_id`, `c0_revision`, `b0_hash`.
|
||||
- `host_session`: `character_id`, `server_id`, `local_guid`.
|
||||
|
||||
### 2.5 Remote Transfer & Control Protocol (`crates/coa-control-proto/src/app.rs`)
|
||||
- `Request::TransferOffer`:
|
||||
`{ transfer_id, character_id, canonical_revision, content_hash, total_size, collections }`
|
||||
-> **Missing `ruleset`**. An offer must bind `ruleset: Ruleset` so the host can reject before chunk transport or preflight if target realm mismatch occurs.
|
||||
- `Request::TransferCommit`:
|
||||
Currently relies on cached transfer metadata.
|
||||
- `Response::TransferCommitted`:
|
||||
Returns session parameters.
|
||||
|
||||
### 2.6 Runtime Sessions & Checkpoints (`crates/coa-core/src/portable/session/protocol.rs`)
|
||||
- `PortableSessionStarted`: `{ protocol_version, session_id, character_id, server_id, base_canonical_revision, baseline_generation, b0, content_hash, progression }`
|
||||
-> **Missing `ruleset`**.
|
||||
- `PortableCheckpoint`: `{ protocol_version, session_id, character_id, server_id, base_canonical_revision, sequence, final_checkpoint, realm_snapshot, content_hash, pin }`
|
||||
-> **Missing `ruleset`**.
|
||||
- `OwnerAck`: `{ protocol_version, session_id, sequence, outcome, canonical_revision, canonical_hash, owned_items, owned_pets, canonical, next_session, pin }`
|
||||
-> Must verify ruleset match in owner session state before applying 3-way merge.
|
||||
|
||||
### 2.7 Direct & Relay Routing
|
||||
- **Direct Ingress Proxy (`crates/coa-core/src/control/direct_ingress.rs`):**
|
||||
Listens on direct auth and world ports, intercepts `CMD_REALM_LIST` from the Auth server, and rewrites the realm entry address to the public direct world IP and port. When multiple realms are active (simultaneous CoA + Wildcard), each published realm must target its respective local world port (`ports.world` vs `s.secondary_world_port`).
|
||||
- **Relay Hub & Allocations (`services/coa-relay/`, `crates/coa-core/src/control/relay_link.rs`):**
|
||||
Relay allocation requests are authenticated by Ed25519 realm key. Allocations bind a short-lived token to an isolated auth/world stream pair.
|
||||
- **Multi-Realm Publication on One Host:**
|
||||
Each published realm possesses its own distinct local realm identity (`srv-<id>:<ruleset>`), unique `RealmId` (UUIDv7), and independent Ed25519 signing key in `KeyStore`. Direct Ingress proxy and Relay links dynamically route to the respective realm's active world port.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+14
-8
@@ -4,10 +4,16 @@ Overview offers CoA and Wildcard. The installed worldserver must contain Wildcar
|
||||
already have its Wildcard migrations. The Manager refuses an unsupported binary, an occupied realm ID 2 or an
|
||||
existing destination database rather than overwrite them.
|
||||
|
||||
The first switch copies the CoA world into `acore_world_wildcard` and creates `acore_characters_wildcard` from
|
||||
the character schema and neutral server seed tables. Existing characters, card collections and progress are not
|
||||
copied. Accounts remain in shared `acore_auth`. Realm IDs 1 and 2 share the world port; only the selected world
|
||||
runs, and the inactive realm is listed offline. A switch while running stops the server gracefully and restarts it.
|
||||
CoA and Wildcard have separate character and world databases (`acore_characters` / `acore_world` for CoA, and
|
||||
`acore_characters_wildcard` / `acore_world_wildcard` for Wildcard). Accounts remain in shared `acore_auth`.
|
||||
|
||||
When simultaneous realm execution is enabled in configuration (`simultaneous: true`), both CoA and Wildcard
|
||||
realms can run concurrently:
|
||||
- Primary and secondary world ports (`ports.world` and `secondary_world_port`, e.g. 8085 and 8086) and RA ports
|
||||
are distinct.
|
||||
- Remote routing (both Direct Ingress and Relay links) is strictly bound to the specific ruleset realm selected:
|
||||
routing to a Wildcard realm targets the Wildcard world port and never advertises or connects to the CoA port.
|
||||
- When simultaneous execution is disabled, only the currently active realm runs and provides world service.
|
||||
|
||||
Configuration profiles are saved under `Settings/realm-profiles`. An interrupted switch can restore its previous
|
||||
configuration from the journal. CoA companion bots cannot be enabled on Wildcard. Content Scaling and the other
|
||||
@@ -21,12 +27,12 @@ adapter before the next start.
|
||||
|
||||
## Portable Characters on Wildcard
|
||||
|
||||
As of v0.7.1 (MR2), Wildcard realms fully participate in the Portable Characters subsystem with strict multi-ruleset isolation:
|
||||
As of v0.7.1, Wildcard realms participate in the Portable Characters subsystem with strict multi-ruleset isolation:
|
||||
- **Canonical Model:** Wildcard portable characters carry `ruleset: "wildcard"` and `content_namespace: "wildcard"`.
|
||||
- **Claim & Export:** Real characters played on a Wildcard realm (`acore_characters_wildcard`) can be claimed and exported into canonical portable snapshots. Wildcard character builds—including spells, talents, skills, action bars, settings, inventory, pets, and currencies—are fully captured.
|
||||
- **Import & Session Runtime:** A Wildcard portable character can be transferred to a remote Wildcard host, imported into its `acore_characters_wildcard` database, armed in a runtime session (Session Protocol 3), and played live with automatic checkpoints and logout synchronization.
|
||||
- **Claim & Export:** Characters played on a Wildcard realm (`acore_characters_wildcard`) can be claimed and exported into canonical portable snapshots. Wildcard character builds—including spells, talents, skills, action bars, settings, inventory, pets, and currencies—are preserved.
|
||||
- **Import & Session Runtime:** A Wildcard portable character can be transferred to a remote Wildcard host, imported into its `acore_characters_wildcard` database, armed in a runtime session (Session Protocol 3), and tracked through checkpoints and logout synchronization.
|
||||
- **Collections:** Wildcard collections (`wildcard:appearance` and `wildcard:vanity`) are partitioned separately from CoA collections. Unlocks on a Wildcard realm union into the profile's Wildcard collections and never touch CoA collections.
|
||||
- **Fail-Closed Protection:** Any attempt to transfer or import a CoA character into a Wildcard realm, or vice versa, is rejected at the protocol and database boundaries with zero mutation.
|
||||
- **Fail-Closed Protection:** Any attempt to transfer or import a CoA character into a Wildcard realm, or vice versa, is rejected at the protocol and database boundaries with zero mutation. If world-port resolution fails or a realm mode is inactive, Direct and Relay routing fail closed.
|
||||
|
||||

|
||||
|
||||
|
||||
Reference in new issue
Block a user