Something went wrong. Try again.
HEXWORK - A Lantern in the Deep: 3D ASCII terminal roguelike RPG in Rust.
Entirely designed and built by Muse Spark 1.3 to evaluate if I like the model.
Something went wrong. Try again.
37 kB · 1113 lines
Rust
at main
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114use 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<_>>(), 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<_>>(), 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<Instance> { let mut instances = state.items.values().cloned().collect::<Vec<_>>(); 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<ItemTransfer, TransferError>, 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());}