use hexwork_core::character::{self, Ancestry, Calling}; use hexwork_core::ecs::StableId; use hexwork_core::inventory::Pack; use hexwork_core::item::{Instance, ItemTemplate}; use hexwork_core::modes::ChapelDebtError; use hexwork_core::reputation::Reputation; use hexwork_core::runtime::{ ActorIdentity, ActorRole, ContractError, ContractStatus, ItemTransfer, KnowledgeCategory, KnowledgeError, LocationError, RunLocation, RuntimeInvariantError, TransferError, Wallet, WalletError, }; use hexwork_core::state::State; fn identity(template: &str, name: &str, role: ActorRole) -> ActorIdentity { ActorIdentity::new(template, name, role) } fn feet(cell: [i32; 3]) -> hexwork_core::spatial::FixedVec3 { hexwork_core::spatial::FixedVec3::from_cell_feet(cell).unwrap() } fn state_with_player() -> (State, u64) { let mut state = State::new(140); let player = state.spawn_actor_with_identity( feet([1, 2, 3]), identity("delver", "Ada", ActorRole::Player), ); state.reputations.insert(player, Reputation::new()); state.initialize_town("milthollow").unwrap(); (state, player) } fn accept(state: &mut State, player: u64, floor: u8) { state .accept_contract("rat_cull", player, floor, 45, 3, "milthollow", 2) .unwrap(); } fn descend_to(state: &mut State, floor: u8) { for depth in 1..=floor { state.transition_location(RunLocation::Deep(depth)).unwrap(); } } #[test] fn state_new_has_empty_knowledge_and_helpers_do_not_advance_rng() { let mut state = State::new(140); assert!(state.knowledge().mapped_regions.is_empty()); assert!(state.knowledge().recipes.is_empty()); assert!(state.knowledge().translated_hymns.is_empty()); let rng_before = state.to_image().unwrap().rng_master; assert_eq!(state.learn_mapped_region("town:milthollow"), Ok(true)); assert_eq!(state.learn_mapped_region("town:milthollow"), Ok(false)); assert_eq!(state.learn_recipe("brine-poultice"), Ok(true)); assert_eq!(state.learn_translation("fiat_lux"), Ok(true)); assert_eq!(state.initialize_known_template("lamp_oil"), Ok(2)); assert_eq!(state.to_image().unwrap().rng_master, rng_before); assert_eq!(state.tick, 0); } #[test] fn effective_identification_uses_instance_flag_or_known_template_book() { let mut state = State::new(140); let mut unknown = Instance::corpse(1, [0.0; 3]); unknown.template = "lamp_oil".into(); unknown.identified = false; assert!(!state.is_effectively_identified(&unknown)); state.idf.insert("lamp_oil".into(), 1); assert!( !state.is_effectively_identified(&unknown), "tried is not known" ); state.initialize_known_template("lamp_oil").unwrap(); assert!(state.is_effectively_identified(&unknown)); state.idf.insert("lamp_oil".into(), 7); assert_eq!(state.initialize_known_template("lamp_oil"), Ok(7)); assert_eq!(state.idf["lamp_oil"], 7, "higher counts never decrease"); let mut instance_known = unknown.clone(); instance_known.template = "other_item".into(); instance_known.identified = true; assert!(state.is_effectively_identified(&instance_known)); assert_eq!(state.idf.get("other_item"), None); } #[test] fn invalid_learning_and_template_initialization_are_atomic() { let mut state = State::new(140); let image_before = state.to_image().unwrap(); assert!(matches!( state.learn_recipe("bad/id"), Err(KnowledgeError::InvalidId { category: KnowledgeCategory::Recipes, .. }) )); assert_eq!(state.knowledge(), &image_before.knowledge); assert!(matches!( state.initialize_known_template("Bad Template"), Err(KnowledgeError::InvalidId { category: KnowledgeCategory::IdentifiedTemplates, .. }) )); assert_eq!(state.idf, image_before.idf); } #[test] fn terminal_lineage_delta_excludes_tried_identifications() { let mut state = State::new(140); state.idf.insert("tried_relic".into(), 1); state.idf.insert("known_lamp".into(), 2); state.idf.insert("mastered_rope".into(), 8); assert_eq!( state.known_templates().collect::>(), vec!["known_lamp", "mastered_rope"] ); assert_eq!( state.idf["tried_relic"], 1, "terminal authority retains tried" ); } #[test] fn spawn_creates_exact_authority_links_without_ecs_mirror() { let (state, player) = state_with_player(); assert_eq!(state.actors.len(), 1); assert_eq!(state.actors[0].id, player); assert_eq!(state.identity(player).unwrap().display_name, "Ada"); assert_eq!(state.wallet(player), Some(&Wallet::default())); assert!(state.ecs.world().is_empty()); assert_eq!(state.validate_runtime(), Ok(())); } #[test] fn chapel_debt_is_private_authority_and_removed_when_worked() { let (mut state, player) = state_with_player(); assert_eq!( state.assign_chapel_debt(999), Err(ChapelDebtError::UnknownActor) ); state.assign_chapel_debt(player).unwrap(); assert_eq!(state.chapel_debt(player).unwrap().burials_owed, 1); assert_eq!(state.work_chapel_debt(player), Ok(true)); assert_eq!(state.chapel_debt(player), None); assert_eq!(state.work_chapel_debt(player), Err(ChapelDebtError::NoDebt)); } #[test] fn chapel_debt_requires_life_and_is_removed_on_death() { let (mut state, player) = state_with_player(); state.actors[0].alive = false; assert_eq!( state.assign_chapel_debt(player), Err(ChapelDebtError::DeadActor) ); state.actors[0].alive = true; state.assign_chapel_debt(player).unwrap(); state.actors[0].hp = 0; state.step(); assert!(!state.actors[0].alive); assert_eq!(state.chapel_debt(player), None); assert_eq!(state.validate_runtime(), Ok(())); } #[test] fn legacy_spawn_also_creates_mandatory_links() { let mut state = State::new(1); let id = state.spawn_actor(feet([0; 3])); assert!(state.identity(id).is_some()); assert!(state.wallet(id).is_some()); assert_eq!(state.validate_runtime(), Ok(())); } #[test] fn spawn_ids_are_unique() { let (mut state, first) = state_with_player(); let second = state.spawn_actor_with_identity(feet([2; 3]), identity("rat", "Rat", ActorRole::Monster)); assert_ne!(first, second); assert_eq!(state.validate_runtime(), Ok(())); } #[test] fn validator_rejects_nonempty_ecs() { let (mut state, _) = state_with_player(); state.ecs.spawn((StableId(99),)); assert_eq!( state.validate_runtime(), Err(RuntimeInvariantError::EcsNotEmpty { entities: 1 }) ); assert!(matches!( state.try_hash_state(), Err(RuntimeInvariantError::EcsNotEmpty { .. }) )); } #[test] fn validator_rejects_duplicate_actor_ids() { let (mut state, _) = state_with_player(); state.actors.push(state.actors[0].clone()); assert!(matches!( state.validate_runtime(), Err(RuntimeInvariantError::DuplicateActorId { .. }) )); } #[test] fn validator_rejects_orphan_optional_actor_map() { let (mut state, _) = state_with_player(); state .characters .insert(999, character::new(Ancestry::Mudborn, Calling::Ratcatcher)); assert_eq!( state.validate_runtime(), Err(RuntimeInvariantError::OrphanActorMetadata { table: "characters", actor_id: 999, }) ); } #[test] fn validator_rejects_item_key_mismatch() { let (mut state, _) = state_with_player(); state.items.insert(3, Instance::corpse(4, [0.0; 3])); assert_eq!( state.validate_runtime(), Err(RuntimeInvariantError::ItemKeyMismatch { table: "items", key: 3, embedded: 4, }) ); } #[test] fn ground_item_spawn_allocates_stable_id_and_hashes_position() { let (mut state, player) = state_with_player(); let template = ItemTemplate::new("chalk_stick", "STONE", "sound"); let id = state.spawn_ground_item(&template, (4, 5, 6)).unwrap(); assert!(id > player); assert_eq!(state.items.get(&id).unwrap().template, "chalk_stick"); assert_eq!(state.ground_items.get(&id), Some(&(4, 5, 6))); let first = state.try_hash_state().unwrap(); state.ground_items.insert(id, (5, 5, 6)); assert_ne!(first, state.try_hash_state().unwrap()); } #[test] fn carried_item_spawn_uses_same_stable_id_space() { let (mut state, player) = state_with_player(); state.packs.insert(player, Pack::new()); let template = ItemTemplate::new("rope_10m", "ROPE", "sound"); let id = state.spawn_carried_item(player, &template).unwrap(); assert_eq!( state.packs.get(&player).unwrap().get(id).unwrap().template, "rope_10m" ); assert!(!state.items.contains_key(&id)); assert!(!state.ground_items.contains_key(&id)); assert_eq!(state.validate_runtime(), Ok(())); } #[test] fn invalid_ground_item_template_does_not_consume_id() { let (mut state, player) = state_with_player(); let bad = ItemTemplate::new("bad", "not-a-material", "sound"); assert!(state.spawn_ground_item(&bad, (0, 0, 0)).is_err()); let actor = state.spawn_actor_with_identity(feet([0; 3]), identity("rat", "Rat", ActorRole::Monster)); assert_eq!(actor, player + 1); } #[test] fn validator_rejects_dangling_ground_item() { let (mut state, _) = state_with_player(); state.ground_items.insert(999, (1, 2, 3)); assert_eq!( state.validate_runtime(), Err(RuntimeInvariantError::DanglingGroundItem { item_id: 999 }) ); } #[test] fn validator_rejects_loose_item_without_ground_position() { let (mut state, _) = state_with_player(); state.items.insert(3, Instance::corpse(3, [0.0; 3])); assert_eq!( state.validate_runtime(), Err(RuntimeInvariantError::LooseItemMissingGround { item_id: 3 }) ); } #[test] fn validator_rejects_zero_and_cross_kind_stable_ids() { let (mut zero_actor, _) = state_with_player(); zero_actor.actors[0].id = 0; assert_eq!( zero_actor.validate_runtime(), Err(RuntimeInvariantError::ZeroStableId { table: "actors" }) ); let (mut zero_loose, _) = state_with_player(); zero_loose.items.insert(0, Instance::corpse(0, [0.0; 3])); zero_loose.ground_items.insert(0, (0, 0, 0)); assert_eq!( zero_loose.validate_runtime(), Err(RuntimeInvariantError::ZeroStableId { table: "items" }) ); let (mut zero_packed, player) = state_with_player(); let mut pack = Pack::new(); pack.add(Instance::corpse(0, [0.0; 3])); zero_packed.packs.insert(player, pack); assert_eq!( zero_packed.validate_runtime(), Err(RuntimeInvariantError::ZeroStableId { table: "packs" }) ); let (mut collision, player) = state_with_player(); collision .items .insert(player, Instance::corpse(player, [0.0; 3])); collision.ground_items.insert(player, (0, 0, 0)); assert_eq!( collision.validate_runtime(), Err(RuntimeInvariantError::StableIdCollision { id: player, first: "actors", second: "items", }) ); let (mut packed_collision, player) = state_with_player(); let mut pack = Pack::new(); pack.add(Instance::corpse(player, [0.0; 3])); packed_collision.packs.insert(player, pack); assert_eq!( packed_collision.validate_runtime(), Err(RuntimeInvariantError::StableIdCollision { id: player, first: "actors", second: "packs", }) ); } #[test] fn validator_rejects_duplicate_item_ownership() { let (mut state, player) = state_with_player(); state.items.insert(3, Instance::corpse(3, [0.0; 3])); state.ground_items.insert(3, (0, 0, 0)); let mut pack = Pack::new(); pack.add(Instance::corpse(3, [0.0; 3])); state.packs.insert(player, pack); assert_eq!( state.validate_runtime(), Err(RuntimeInvariantError::DuplicateItemId { item_id: 3 }) ); } #[test] fn wallet_balances_are_read_only_and_state_transactions_are_checked() { let (mut state, player) = state_with_player(); assert_eq!(state.earn_copper(player, 10), Ok(())); assert_eq!(state.earn_scrip(player, 4), Ok(())); assert_eq!(state.spend_copper(player, 3), Ok(())); assert_eq!(state.spend_scrip(player, 1), Ok(())); assert_eq!(state.wallet(player).unwrap().copper(), 7); assert_eq!(state.wallet(player).unwrap().scrip(), 3); assert_eq!( state.spend_copper(player, 8), Err(WalletError::InsufficientFunds) ); assert_eq!(state.wallet(player).unwrap().copper(), 7); } #[test] fn saturating_earn_returns_actual_credit() { let mut wallet = Wallet::new(u32::MAX - 2, 0); assert_eq!(wallet.saturating_earn_copper(7), 2); assert_eq!(wallet.copper(), u32::MAX); assert_eq!(wallet.saturating_earn_scrip(3), 3); } #[test] fn run_location_starts_uninitialized_and_rejects_blank_town() { let mut state = State::new(4); assert_eq!(state.run_location(), &RunLocation::Uninitialized); assert_eq!(state.initialize_town(" "), Err(LocationError::BlankTown)); assert_eq!(state.run_location(), &RunLocation::Uninitialized); } #[test] fn location_transitions_enforce_adjacency_and_boundaries() { let mut state = State::new(4); state.initialize_town("milthollow").unwrap(); assert_eq!( state.transition_location(RunLocation::Deep(2)), Err(LocationError::IllegalTransition) ); state.transition_location(RunLocation::Deep(1)).unwrap(); state.transition_location(RunLocation::Deep(2)).unwrap(); assert_eq!( state.transition_location(RunLocation::Deep(4)), Err(LocationError::IllegalTransition) ); assert_eq!( state.transition_location(RunLocation::Deep(13)), Err(LocationError::InvalidDepth) ); state.transition_location(RunLocation::Deep(3)).unwrap(); assert_eq!(state.run_location(), &RunLocation::Deep(3)); } #[test] fn only_deep_one_can_return_to_town() { let (mut state, _) = state_with_player(); descend_to(&mut state, 2); assert_eq!( state.transition_location(RunLocation::Town("milthollow".into())), Err(LocationError::IllegalTransition) ); state.transition_location(RunLocation::Deep(1)).unwrap(); state .transition_location(RunLocation::Town("milthollow".into())) .unwrap(); } #[test] fn restore_setter_is_validated_but_not_adjacent() { let mut state = State::new(1); assert_eq!( state.restore_run_location(RunLocation::Town(String::new())), Err(LocationError::BlankTown) ); assert_eq!(state.restore_run_location(RunLocation::Deep(12)), Ok(())); } #[test] fn contract_acceptance_validates_all_inputs() { let (mut state, player) = state_with_player(); assert_eq!( state.accept_contract(" ", player, 1, 1, 1, "milthollow", 1), Err(ContractError::BlankId) ); assert_eq!( state.accept_contract("bad-floor", player, 0, 1, 1, "milthollow", 1), Err(ContractError::InvalidFloor) ); assert_eq!( state.accept_contract("bad-target", player, 1, 1, 0, "milthollow", 1), Err(ContractError::InvalidObjectiveTarget) ); assert_eq!( state.accept_contract("bad-town", player, 1, 1, 1, " ", 1), Err(ContractError::BlankRewardTown) ); assert_eq!( state.accept_contract("orphan", 999, 1, 1, 1, "milthollow", 1), Err(ContractError::UnknownTaker) ); } #[test] fn contract_rejects_dead_taker() { let (mut state, player) = state_with_player(); state.actors[0].alive = false; assert_eq!( state.accept_contract("c", player, 1, 1, 1, "milthollow", 1), Err(ContractError::DeadTaker) ); } #[test] fn contract_acceptance_requires_exact_current_town_without_mutation() { let mut uninitialized = State::new(1); let uninitialized_player = uninitialized.spawn_actor(feet([0; 3])); assert_eq!( uninitialized.accept_contract("c", uninitialized_player, 1, 1, 1, "milthollow", 1), Err(ContractError::WrongLocation) ); assert!(uninitialized.contracts().next().is_none()); let (mut deep, deep_player) = state_with_player(); deep.transition_location(RunLocation::Deep(1)).unwrap(); assert_eq!( deep.accept_contract("c", deep_player, 1, 1, 1, "milthollow", 1), Err(ContractError::WrongLocation) ); assert!(deep.contracts().next().is_none()); let (mut wrong_town, wrong_town_player) = state_with_player(); assert_eq!( wrong_town.accept_contract("c", wrong_town_player, 1, 1, 1, "copperwash", 1), Err(ContractError::WrongLocation) ); assert!(wrong_town.contracts().next().is_none()); } #[test] fn npc_and_monster_deaths_preserve_delver_knowledge() { let (mut state, _) = state_with_player(); state.learn_mapped_region("town:milthollow").unwrap(); state.learn_recipe("brine-poultice").unwrap(); state.learn_translation("fiat_lux").unwrap(); state.idf.insert("tried_relic".into(), 1); state.idf.insert("known_lamp".into(), 3); let expected_knowledge = state.knowledge().clone(); let expected_idf = state.idf.clone(); let npc = state.spawn_actor_with_identity(feet([2; 3]), identity("reeve", "Rennick", ActorRole::Npc)); let monster = state.spawn_actor_with_identity(feet([3; 3]), identity("rat", "Cable-rat", ActorRole::Monster)); state .actors .iter_mut() .find(|actor| actor.id == npc) .unwrap() .hp = 0; state.step(); assert_eq!(state.knowledge(), &expected_knowledge); assert_eq!(state.idf, expected_idf); state .resolve_melee_exchange(1, monster, i32::MAX, 3, None) .unwrap(); assert_eq!(state.knowledge(), &expected_knowledge); assert_eq!(state.idf, expected_idf); assert_eq!( state.items.len(), 2, "ordinary and melee deaths each mint a corpse" ); } #[test] fn player_death_retains_typed_terminal_knowledge() { let (mut state, player) = state_with_player(); state.learn_mapped_region("deep:1").unwrap(); state.learn_recipe("brine-poultice").unwrap(); state.learn_translation("fiat_lux").unwrap(); state.idf.insert("tried_relic".into(), 1); state.idf.insert("known_lamp".into(), 2); let expected_knowledge = state.knowledge().clone(); let expected_idf = state.idf.clone(); state .actors .iter_mut() .find(|actor| actor.id == player) .unwrap() .hp = 0; state.step(); assert_eq!(state.knowledge(), &expected_knowledge); assert_eq!(state.idf, expected_idf); assert_eq!( state.known_templates().collect::>(), vec!["known_lamp"] ); assert!(!state .causes .links() .iter() .any(|link| link.detail.contains("vault keeps"))); } #[test] fn active_contract_fails_when_taker_dies_during_step() { let (mut state, player) = state_with_player(); accept(&mut state, player, 1); state.actors[0].hp = 0; state.step(); assert_eq!( state.contract("rat_cull").unwrap().status, ContractStatus::Failed ); assert_eq!(state.validate_runtime(), Ok(())); assert!(state.try_hash_state().is_ok()); } #[test] fn completed_contract_fails_and_terminal_contracts_survive_taker_death() { let (mut state, player) = completed_contract(); state .accept_contract("terminal", player, 1, 7, 1, "milthollow", 0) .unwrap(); state.fail_contract("terminal").unwrap(); state.actors[0].hp = 0; state.step(); assert_eq!( state.contract("rat_cull").unwrap().status, ContractStatus::Failed ); assert_eq!( state.contract("terminal").unwrap().status, ContractStatus::Failed ); assert_eq!(state.validate_runtime(), Ok(())); assert!(state.try_hash_state().is_ok()); } #[test] fn turned_in_contract_survives_taker_death() { let (mut state, player) = completed_contract(); state.turn_in_contract("rat_cull").unwrap(); state.actors[0].hp = 0; state.step(); assert_eq!( state.contract("rat_cull").unwrap().status, ContractStatus::TurnedIn ); assert_eq!(state.validate_runtime(), Ok(())); assert!(state.try_hash_state().is_ok()); assert!( !state .actors .iter() .find(|actor| actor.id == player) .unwrap() .alive ); } #[test] fn contract_progress_is_scoped_to_taker_and_matching_floor() { let (mut state, player) = state_with_player(); let other = state.spawn_actor_with_identity(feet([2; 3]), identity("other", "Other", ActorRole::Player)); accept(&mut state, player, 2); assert_eq!( state.advance_contract_objective("rat_cull", player, 1), Err(ContractError::WrongLocation) ); descend_to(&mut state, 2); assert_eq!( state.advance_contract_objective("rat_cull", other, 1), Err(ContractError::WrongTaker) ); assert_eq!( state.advance_contract_objective("rat_cull", player, 2), Ok(()) ); assert_eq!(state.contract("rat_cull").unwrap().objective_progress, 2); } #[test] fn objective_clamps_and_completes() { let (mut state, player) = state_with_player(); accept(&mut state, player, 1); descend_to(&mut state, 1); state .advance_contract_objective("rat_cull", player, u32::MAX) .unwrap(); let contract = state.contract("rat_cull").unwrap(); assert_eq!(contract.objective_progress, 3); assert_eq!(contract.status, ContractStatus::Completed); assert_eq!( state.advance_contract_objective("rat_cull", player, 1), Err(ContractError::NotActive) ); } #[test] fn fail_transition_is_terminal_and_idempotent() { let (mut state, player) = state_with_player(); accept(&mut state, player, 1); assert_eq!(state.fail_contract("rat_cull"), Ok(())); assert_eq!( state.contract("rat_cull").unwrap().status, ContractStatus::Failed ); assert_eq!(state.fail_contract("rat_cull"), Err(ContractError::NotOpen)); } fn completed_contract() -> (State, u64) { let (mut state, player) = state_with_player(); accept(&mut state, player, 1); descend_to(&mut state, 1); state .advance_contract_objective("rat_cull", player, 3) .unwrap(); state .transition_location(RunLocation::Town("milthollow".into())) .unwrap(); (state, player) } #[test] fn turn_in_requires_exact_reward_town() { let (mut state, _) = completed_contract(); state .restore_run_location(RunLocation::Town("copperwash".into())) .unwrap(); assert_eq!( state.turn_in_contract("rat_cull"), Err(ContractError::WrongLocation) ); } #[test] fn turn_in_atomically_credits_copper_and_standing_once() { let (mut state, player) = completed_contract(); assert_eq!(state.turn_in_contract("rat_cull"), Ok(())); assert_eq!(state.wallet(player).unwrap().copper(), 45); assert_eq!(state.reputations[&player].ledger("milthollow"), 2); assert_eq!( state.turn_in_contract("rat_cull"), Err(ContractError::NotCompleted) ); assert_eq!(state.wallet(player).unwrap().copper(), 45); assert_eq!(state.reputations[&player].ledger("milthollow"), 2); } #[test] fn missing_reputation_aborts_payout_without_wallet_mutation() { let (mut state, player) = completed_contract(); state.reputations.remove(&player); assert_eq!( state.turn_in_contract("rat_cull"), Err(ContractError::MissingReputation) ); assert_eq!(state.wallet(player).unwrap().copper(), 0); assert_eq!( state.contract("rat_cull").unwrap().status, ContractStatus::Completed ); } #[test] fn payout_overflow_is_atomic() { let (mut state, player) = completed_contract(); state.earn_copper(player, u32::MAX).unwrap(); assert_eq!( state.turn_in_contract("rat_cull"), Err(ContractError::PayoutOverflow) ); assert_eq!(state.wallet(player).unwrap().copper(), u32::MAX); assert_eq!(state.reputations[&player].ledger("milthollow"), 0); assert_eq!( state.contract("rat_cull").unwrap().status, ContractStatus::Completed ); } fn transfer_state(position: [i64; 3]) -> (State, u64) { let position = hexwork_core::spatial::FixedVec3::new(position).unwrap(); let mut state = State::new(140); let actor = state.spawn_actor_with_identity(position, identity("delver", "Ada", ActorRole::Player)); state.packs.insert(actor, Pack::new()); (state, actor) } fn transfer_template(id: &str) -> ItemTemplate { ItemTemplate::new(id, "STONE", "sound") } fn exact_instances(state: &State) -> Vec { let mut instances = state.items.values().cloned().collect::>(); instances.extend( state .packs .values() .flat_map(|pack| pack.items.values().cloned()), ); instances.sort_by_key(|item| item.id); instances } fn assert_hash_rejection_unchanged( state: &mut State, operation: impl FnOnce(&mut State) -> Result, expected: TransferError, ) { let before = state.try_hash_state().unwrap(); let before_image = format!("{:#?}", state.to_image().unwrap()); assert_eq!(operation(state), Err(expected)); assert_eq!(state.try_hash_state().unwrap(), before); assert_eq!(format!("{:#?}", state.to_image().unwrap()), before_image); } #[test] fn transfer_conserves_instances_ids_and_unrelated_state() { let (mut state, actor) = transfer_state([-1500, 2490, -3500]); state.learn_mapped_region("deep:1").unwrap(); state.learn_recipe("brine-poultice").unwrap(); state.learn_translation("fiat_lux").unwrap(); state.idf.insert("chalk_stick".into(), 2); state.earn_copper(actor, 17).unwrap(); state.fall_odo.insert(actor, 8500); state.packs.get_mut(&actor).unwrap().max_weight_kg = f32::MIN_POSITIVE; state.packs.get_mut(&actor).unwrap().max_bulk = 0; let first = state .spawn_ground_item(&transfer_template("chalk_stick"), (-2, 2, -4)) .unwrap(); let second = state .spawn_ground_item(&transfer_template("stone_token"), (-2, 2, -4)) .unwrap(); let unrelated = state .spawn_ground_item(&transfer_template("remote_token"), (90, 91, 92)) .unwrap(); let instances_before = exact_instances(&state); let image_before = state.to_image().unwrap(); assert_eq!( state.pickup_ground_item(actor, second), Ok(ItemTransfer { item_id: second, cell: (-2, 2, -4), }) ); assert!(state.items.contains_key(&first)); assert!(state.items.contains_key(&unrelated)); assert_eq!(state.ground_items[&unrelated], (90, 91, 92)); assert!(state.packs[&actor].contains(second)); assert_eq!(exact_instances(&state), instances_before); for _ in 0..3 { assert_eq!( state.drop_carried_item(actor, second), Ok(ItemTransfer { item_id: second, cell: (-2, 2, -4), }) ); assert_eq!(state.ground_items[&second], (-2, 2, -4)); assert_eq!( state.pickup_ground_item(actor, second), Ok(ItemTransfer { item_id: second, cell: (-2, 2, -4), }) ); assert_eq!(exact_instances(&state), instances_before); assert_eq!(state.validate_runtime(), Ok(())); } let image_after = state.to_image().unwrap(); assert_eq!(image_after.next_id, image_before.next_id); assert_eq!(image_after.knowledge, image_before.knowledge); assert_eq!(image_after.idf, image_before.idf); assert_eq!(image_after.wallets, image_before.wallets); assert_eq!(image_after.contracts, image_before.contracts); assert_eq!(image_after.fall_odo, image_before.fall_odo); assert_eq!(image_after.tick, image_before.tick); assert_eq!(image_after.rng_master, image_before.rng_master); assert_eq!(exact_instances(&state), instances_before); let mut normalized_after = image_after; normalized_after.items = image_before.items.clone(); normalized_after.ground_items = image_before.ground_items.clone(); normalized_after.packs = image_before.packs.clone(); assert_eq!( format!("{normalized_after:#?}"), format!("{image_before:#?}"), "only ownership tables may differ after a transfer", ); } #[test] fn cursed_transfer_and_equipment_rules() { let (mut state, actor) = transfer_state([4500, -1500, 7000]); let cursed = state .spawn_ground_item(&transfer_template("cursed_stone"), (4, -2, 7)) .unwrap(); state.items.get_mut(&cursed).unwrap().cursed = true; assert_eq!( state.pickup_ground_item(actor, cursed), Ok(ItemTransfer { item_id: cursed, cell: (4, -2, 7), }) ); assert_hash_rejection_unchanged( &mut state, |state| state.drop_carried_item(actor, cursed), TransferError::Stuck, ); state.packs.get_mut(&actor).unwrap().equipped_weapon = Some(cursed); assert_hash_rejection_unchanged( &mut state, |state| state.drop_carried_item(actor, cursed), TransferError::Stuck, ); state.packs.get_mut(&actor).unwrap().equipped_weapon = None; state.packs.get_mut(&actor).unwrap().equipped_armor = Some(cursed); assert_hash_rejection_unchanged( &mut state, |state| state.drop_carried_item(actor, cursed), TransferError::Stuck, ); state.packs.get_mut(&actor).unwrap().equipped_armor = None; state .packs .get_mut(&actor) .unwrap() .items .get_mut(&cursed) .unwrap() .cursed = false; let pack = state.packs.get_mut(&actor).unwrap(); pack.equipped_weapon = Some(cursed); pack.equipped_armor = Some(cursed); assert_eq!( state.drop_carried_item(actor, cursed), Ok(ItemTransfer { item_id: cursed, cell: (4, -2, 7), }) ); assert_eq!(state.packs[&actor].equipped_weapon, None); assert_eq!(state.packs[&actor].equipped_armor, None); assert_eq!(state.validate_runtime(), Ok(())); } #[test] fn transfer_rejections_are_pre_mutation() { let (mut base, actor) = transfer_state([1500, 2500, 3000]); let local = base .spawn_ground_item(&transfer_template("local"), (1, 2, 3)) .unwrap(); let remote = base .spawn_ground_item(&transfer_template("remote"), (8, 9, 10)) .unwrap(); let other = base.spawn_actor_with_identity( feet([1, 2, 3]), identity("other", "Other", ActorRole::Player), ); base.packs.insert(other, Pack::new()); let carried_by_other = base .spawn_carried_item(other, &transfer_template("other_item")) .unwrap(); let mut unknown = State::new(1); assert_hash_rejection_unchanged( &mut unknown, |state| state.pickup_ground_item(999, 1), TransferError::UnknownActor, ); assert_hash_rejection_unchanged( &mut unknown, |state| state.drop_carried_item(999, 1), TransferError::UnknownActor, ); let mut dead = State::from_image(base.to_image().unwrap()).unwrap(); dead.actors .iter_mut() .find(|a| a.id == actor) .unwrap() .alive = false; assert_hash_rejection_unchanged( &mut dead, |state| state.pickup_ground_item(actor, local), TransferError::DeadActor, ); dead.actors .iter_mut() .find(|a| a.id == actor) .unwrap() .alive = true; dead.actors.iter_mut().find(|a| a.id == actor).unwrap().hp = 0; assert_hash_rejection_unchanged( &mut dead, |state| state.drop_carried_item(actor, local), TransferError::DeadActor, ); let mut missing_pack = State::from_image(base.to_image().unwrap()).unwrap(); missing_pack.packs.remove(&actor); assert_hash_rejection_unchanged( &mut missing_pack, |state| state.pickup_ground_item(actor, local), TransferError::MissingPack, ); assert_hash_rejection_unchanged( &mut missing_pack, |state| state.drop_carried_item(actor, local), TransferError::MissingPack, ); assert_hash_rejection_unchanged( &mut base, |state| state.pickup_ground_item(actor, u64::MAX), TransferError::GroundItemMissing, ); assert_hash_rejection_unchanged( &mut base, |state| state.pickup_ground_item(actor, carried_by_other), TransferError::GroundItemMissing, ); assert_hash_rejection_unchanged( &mut base, |state| state.pickup_ground_item(actor, remote), TransferError::GroundItemElsewhere, ); assert_hash_rejection_unchanged( &mut base, |state| state.drop_carried_item(actor, local), TransferError::CarriedItemMissing, ); assert_hash_rejection_unchanged( &mut base, |state| state.drop_carried_item(actor, carried_by_other), TransferError::CarriedItemMissing, ); let mut invalid = State::from_image(base.to_image().unwrap()).unwrap(); invalid.ground_items.remove(&local); let before = format!("{invalid:#?}"); let expected = invalid.validate_runtime().unwrap_err(); assert_eq!( invalid.pickup_ground_item(actor, local), Err(TransferError::InvalidRuntime(expected)) ); assert_eq!(format!("{invalid:#?}"), before); } #[test] fn transfer_coordinate_edges_and_error_precedence() { for (position, cell) in [ ([-1490, -1500, -490], (-2, -2, -1)), ([1490, 1500, 500], (1, 1, 0)), ( [-2_147_483_520_000, 2_147_483_520_000, 0], (-2_147_483_520, 2_147_483_520, 0), ), ] { let (mut state, actor) = transfer_state(position); let item = state .spawn_ground_item(&transfer_template("edge"), cell) .unwrap(); assert_eq!( state.pickup_ground_item(actor, item), Ok(ItemTransfer { item_id: item, cell }) ); assert_eq!( state.drop_carried_item(actor, item), Ok(ItemTransfer { item_id: item, cell }) ); } use hexwork_core::spatial::{FixedVec3, MIN_POSITION_MM, MAX_POSITION_MM}; assert!(FixedVec3::new([i64::MAX, 0, 0]).is_err()); assert!(FixedVec3::new([i64::MIN, 0, 0]).is_err()); for position in [[MIN_POSITION_MM, 0, 0], [MAX_POSITION_MM, 0, 0], [0, 0, MAX_POSITION_MM]] { let (mut state, actor) = transfer_state([500, 500, 0]); let item = state .spawn_ground_item(&transfer_template("invalid_position"), (0, 0, 0)) .unwrap(); state.actors[0].position = FixedVec3::new(position).unwrap(); let before = format!("{state:#?}"); let expected = state.validate_runtime().unwrap_err(); assert_eq!( state.pickup_ground_item(actor, item), Err(TransferError::InvalidRuntime(expected.clone())) ); assert_eq!(format!("{state:#?}"), before); assert_eq!( state.drop_carried_item(999, item), Err(TransferError::InvalidRuntime(expected)) ); assert_eq!(format!("{state:#?}"), before); } } #[test] fn maximum_character_counters_hash_without_debug_overflow() { let (mut state, player) = state_with_player(); let mut character = character::new(Ancestry::Foundling, Calling::Tinker); character.attrs = [u8::MAX; character::ATTRIBUTE_COUNT]; character.skills = [u8::MAX; character::SKILL_COUNT]; character.uses = [u32::MAX; character::SKILL_COUNT]; character.pips = [u8::MAX; character::SKILL_COUNT]; state.characters.insert(player, character); assert!(state.try_hash_state().is_ok()); } #[test] fn same_seed_and_same_runtime_actions_hash_equal() { let (mut first, first_player) = state_with_player(); let (mut second, second_player) = state_with_player(); for (state, player) in [(&mut first, first_player), (&mut second, second_player)] { state.earn_copper(player, 8).unwrap(); accept(state, player, 1); descend_to(state, 1); state .advance_contract_objective("rat_cull", player, 1) .unwrap(); } assert_eq!(first.hash_state(), second.hash_state()); }