1use core::marker::PhantomData;
9
10use arkhe_kernel::abi::{EntityId, Tick};
11use serde::{Deserialize, Serialize};
12
13use crate::action::{ActionCompute, ArkheAction as _};
14use crate::brand::{ShellBrand, ShellId};
15use crate::component::{ArkheComponent as _, BoundedString};
16use crate::context::{ensure_schema_version, ActionContext, ActionError};
17use crate::user::UserId;
18use crate::ArkheAction;
19use crate::ArkheComponent;
20use crate::arkhe_pure;
22
23#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Serialize, Deserialize)]
25#[serde(transparent)]
26pub struct ActorId(EntityId);
27
28impl ActorId {
29 #[inline]
33 #[must_use]
34 pub fn new(id: EntityId) -> Self {
35 Self(id)
36 }
37
38 #[inline]
40 #[must_use]
41 pub fn get(self) -> EntityId {
42 self.0
43 }
44}
45
46#[non_exhaustive]
48#[repr(u8)]
49#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
50pub enum ActorKind {
51 Human = 0,
53 Bot = 1,
55 System = 2,
57 Anonymous = 3,
59}
60
61mod state_seal {
62 pub trait Sealed {}
64}
65
66pub trait ActorState: state_seal::Sealed + 'static {
72 const NAME: &'static str;
74}
75
76#[derive(Debug)]
78pub enum Anonymous {}
79#[derive(Debug)]
81pub enum Authenticated {}
82#[derive(Debug)]
84pub enum Suspended {}
85
86impl state_seal::Sealed for Anonymous {}
87impl state_seal::Sealed for Authenticated {}
88impl state_seal::Sealed for Suspended {}
89
90impl ActorState for Anonymous {
91 const NAME: &'static str = "anonymous";
92}
93impl ActorState for Authenticated {
94 const NAME: &'static str = "authenticated";
95}
96impl ActorState for Suspended {
97 const NAME: &'static str = "suspended";
98}
99
100pub struct Actor<'s, S: ActorState> {
106 brand: ShellBrand<'s>,
107 id: ActorId,
108 _state: PhantomData<fn() -> S>,
109}
110
111impl<'s, S: ActorState> Clone for Actor<'s, S> {
112 #[inline]
113 fn clone(&self) -> Self {
114 *self
115 }
116}
117impl<'s, S: ActorState> Copy for Actor<'s, S> {}
118
119impl<'s, S: ActorState> core::fmt::Debug for Actor<'s, S> {
120 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
121 f.debug_struct("Actor")
122 .field("id", &self.id)
123 .field("state", &S::NAME)
124 .finish()
125 }
126}
127
128impl<'s, S: ActorState> Actor<'s, S> {
129 #[inline]
131 #[must_use]
132 pub fn id(self) -> ActorId {
133 self.id
134 }
135
136 #[inline]
138 #[must_use]
139 pub fn brand(self) -> ShellBrand<'s> {
140 self.brand
141 }
142}
143
144impl<'s> Actor<'s, Anonymous> {
145 #[inline]
147 #[must_use]
148 pub fn new_anonymous(brand: ShellBrand<'s>, id: ActorId) -> Self {
149 Self {
150 brand,
151 id,
152 _state: PhantomData,
153 }
154 }
155
156 #[inline]
160 #[must_use]
161 pub fn authenticate(self, _user_id: UserId) -> Actor<'s, Authenticated> {
162 Actor {
163 brand: self.brand,
164 id: self.id,
165 _state: PhantomData,
166 }
167 }
168}
169
170impl<'s> Actor<'s, Authenticated> {
171 #[inline]
175 #[must_use]
176 pub fn suspend(self) -> Actor<'s, Suspended> {
177 Actor {
178 brand: self.brand,
179 id: self.id,
180 _state: PhantomData,
181 }
182 }
183}
184
185#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, ArkheComponent)]
187#[arkhe(type_code = 0x0003_0101, schema_version = 1)]
188pub struct ActorProfile {
189 pub schema_version: u16,
191 pub shell_id: ShellId,
193 pub handle: BoundedString<32>,
195 pub kind: ActorKind,
197 pub created_tick: Tick,
199}
200
201#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, ArkheComponent)]
204#[arkhe(type_code = 0x0003_0102, schema_version = 1)]
205pub struct UserBinding {
206 pub schema_version: u16,
208 pub user_id: UserId,
210}
211
212#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, ArkheAction)]
234#[arkhe(type_code = 0x0001_0101, schema_version = 1, band = 1)]
235pub struct RegisterActor {
236 pub schema_version: u16,
238 pub profile: ActorProfile,
240 pub user: UserId,
242}
243
244impl ActionCompute for RegisterActor {
245 #[arkhe_pure]
246 fn compute<'i>(&self, ctx: &mut ActionContext<'i>) -> Result<(), ActionError> {
247 ensure_schema_version(Self::SCHEMA_VERSION, self.schema_version)?;
251 ensure_schema_version(ActorProfile::SCHEMA_VERSION, self.profile.schema_version)?;
252
253 if ctx
256 .actor_by_handle(self.profile.shell_id, &self.profile.handle)
257 .is_some()
258 {
259 return Err(ActionError::ActorHandleCollision {
260 shell_id: self.profile.shell_id,
261 handle: self.profile.handle.clone(),
262 });
263 }
264
265 let actor_entity = ctx.spawn_entity_for::<ActorProfile>()?;
266 ctx.set_component(actor_entity, &self.profile)?;
267 ctx.set_component(
268 actor_entity,
269 &UserBinding {
270 schema_version: UserBinding::SCHEMA_VERSION,
271 user_id: self.user,
272 },
273 )?;
274 Ok(())
275 }
276}
277
278#[cfg(test)]
279#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
280mod tests {
281 use super::*;
282 use crate::component::ArkheComponent;
283
284 fn ent(v: u64) -> EntityId {
285 EntityId::new(v).unwrap()
286 }
287
288 #[test]
289 fn actor_typestate_transitions_anonymous_authenticated_suspended() {
290 ShellBrand::run(|brand| {
291 let id = ActorId::new(ent(1));
292 let anon: Actor<'_, Anonymous> = Actor::new_anonymous(brand, id);
293 let user_id = UserId::new(ent(2));
294 let auth: Actor<'_, Authenticated> = anon.authenticate(user_id);
295 let susp: Actor<'_, Suspended> = auth.suspend();
296 assert_eq!(susp.id(), id);
297 });
298 }
299
300 #[test]
301 fn actor_state_names_are_distinct() {
302 assert_eq!(Anonymous::NAME, "anonymous");
303 assert_eq!(Authenticated::NAME, "authenticated");
304 assert_eq!(Suspended::NAME, "suspended");
305 }
306
307 #[test]
308 fn actor_profile_serde_roundtrip_postcard() {
309 let p = ActorProfile {
310 schema_version: 1,
311 shell_id: ShellId([0xAB; 16]),
312 handle: BoundedString::<32>::new("alice").unwrap(),
313 kind: ActorKind::Human,
314 created_tick: Tick(100),
315 };
316 let bytes = postcard::to_stdvec(&p).unwrap();
317 let back: ActorProfile = postcard::from_bytes(&bytes).unwrap();
318 assert_eq!(p, back);
319 }
320
321 #[test]
322 fn actor_profile_exposes_type_code_and_schema_version() {
323 assert_eq!(ActorProfile::TYPE_CODE, 0x0003_0101);
324 assert_eq!(ActorProfile::SCHEMA_VERSION, 1);
325 }
326
327 fn test_ctx() -> ActionContext<'static> {
328 use arkhe_kernel::abi::{CapabilityMask, InstanceId, Principal};
329 ActionContext::new(
330 [0u8; 32],
331 InstanceId::new(1).unwrap(),
332 Tick(7),
333 Principal::System,
334 CapabilityMask::SYSTEM,
335 )
336 }
337
338 fn register_actor(v: u64) -> RegisterActor {
339 RegisterActor {
340 schema_version: 1,
341 profile: ActorProfile {
342 schema_version: 1,
343 shell_id: ShellId([0xAB; 16]),
344 handle: BoundedString::<32>::new("alice").unwrap(),
345 kind: ActorKind::Human,
346 created_tick: Tick(7),
347 },
348 user: UserId::new(ent(v)),
349 }
350 }
351
352 #[test]
353 fn register_actor_spawns_actor_then_sets_profile_and_binding() {
354 use arkhe_kernel::abi::TypeCode;
355 use arkhe_kernel::state::Op;
356
357 let mut c = test_ctx();
358 register_actor(7).compute(&mut c).expect("compute ok");
359 let ops = c.drain_ops();
360 assert_eq!(ops.len(), 3, "spawn + ActorProfile + UserBinding");
361
362 let Op::SpawnEntity { id: actor_id, .. } = &ops[0] else {
363 panic!("op 0 must spawn the actor entity, got {:?}", ops[0]);
364 };
365 match &ops[1] {
366 Op::SetComponent {
367 entity, type_code, ..
368 } => {
369 assert_eq!(entity, actor_id, "profile lands on the spawned actor");
370 assert_eq!(*type_code, TypeCode(ActorProfile::TYPE_CODE));
371 }
372 other => panic!("expected SetComponent(ActorProfile), got {:?}", other),
373 }
374 match &ops[2] {
375 Op::SetComponent {
376 entity,
377 type_code,
378 bytes,
379 ..
380 } => {
381 assert_eq!(entity, actor_id, "binding lands on the spawned actor");
382 assert_eq!(*type_code, TypeCode(UserBinding::TYPE_CODE));
383 let binding: UserBinding = postcard::from_bytes(bytes).unwrap();
384 assert_eq!(binding.user_id, UserId::new(ent(7)));
385 }
386 other => panic!("expected SetComponent(UserBinding), got {:?}", other),
387 }
388 }
389
390 #[test]
391 fn register_actor_rejects_handle_collision_via_index() {
392 use crate::context::ActorHandleIndex;
393
394 struct OneOccupant {
395 shell: ShellId,
396 handle: BoundedString<32>,
397 holder: ActorId,
398 }
399 impl ActorHandleIndex for OneOccupant {
400 fn lookup(&self, shell: ShellId, handle: &BoundedString<32>) -> Option<ActorId> {
401 (shell == self.shell && *handle == self.handle).then_some(self.holder)
402 }
403 }
404
405 let act = register_actor(7);
406 let index = OneOccupant {
407 shell: act.profile.shell_id,
408 handle: act.profile.handle.clone(),
409 holder: ActorId::new(ent(99)),
410 };
411 let mut c = test_ctx().with_actor_handle_index(&index);
412 let err = act
413 .compute(&mut c)
414 .expect_err("occupied handle must reject");
415 match err {
416 ActionError::ActorHandleCollision { shell_id, handle } => {
417 assert_eq!(shell_id, act.profile.shell_id);
418 assert_eq!(handle, act.profile.handle);
419 }
420 other => panic!("expected ActorHandleCollision, got {:?}", other),
421 }
422 assert!(c.ops().is_empty(), "no Ops on rejection");
423 }
424
425 #[test]
426 fn register_actor_rejects_wire_schema_mismatch() {
427 let mut c = test_ctx();
428
429 let mut act = register_actor(7);
430 act.schema_version = 0xBEEF;
431 let err = act.compute(&mut c).expect_err("action field");
432 assert!(
433 matches!(
434 err,
435 ActionError::SchemaMismatch {
436 expected: 1,
437 got: 0xBEEF,
438 }
439 ),
440 "got {err:?}",
441 );
442 assert!(c.ops().is_empty(), "no Ops on rejection");
443
444 let mut act = register_actor(7);
445 act.profile.schema_version = 0xBEEF;
446 let err = act.compute(&mut c).expect_err("profile field");
447 assert!(
448 matches!(
449 err,
450 ActionError::SchemaMismatch {
451 expected: 1,
452 got: 0xBEEF,
453 }
454 ),
455 "got {err:?}",
456 );
457 assert!(c.ops().is_empty(), "no Ops on rejection");
458 }
459
460 #[test]
461 fn register_actor_exposes_trait_consts() {
462 use crate::action::ArkheAction;
463 assert_eq!(RegisterActor::TYPE_CODE, 0x0001_0101);
464 assert_eq!(RegisterActor::SCHEMA_VERSION, 1);
465 assert_eq!(RegisterActor::BAND, 1);
466 const { assert!(!RegisterActor::IDEMPOTENT) };
467 }
468}