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.
15 kB · 390 lines
Rust
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391//! AC5/6/7: ordinary generated acquisition, actual process purchase and PTY.#[path = "support/mod.rs"]pub mod support;use hexwork_core::{action::CommittedAction, modes::Mode};use hexwork_save::{ decode_run, encode_replay, read_file_bounded, DecodedRun, ProductionReplay, SessionEventKind, MAX_COMPRESSED_BYTES,};use std::{fs, path::Path, process::Command};use support::Harness;
// Ordinary seed140 route: Town -> D1 -> D2 -> D3 -> D4 -> hymn card -> back to the// tinker. Real movement advances 300 mm per Move, walkers leave their posts, and// the deep floors hold chasing monsters, so a frozen input list cannot survive the// trip. Every leg is instead re-planned from the durable cell after the previous// batch, using only public movement, connector Confirm and ground Pickup inputs.const SEED: u64 = 140;
fn paired<'a>( connectors: &'a [hexwork_worldgen::ConnectorPlan], source: &hexwork_worldgen::ConnectorPlan,) -> &'a hexwork_worldgen::ConnectorPlan { connectors .iter() .find(|c| c.transition_id == source.paired_transition_id) .expect("paired connector")}
fn ok(c: Command) -> std::process::Output { let out = Harness::run(c); assert!( out.status.success(), "stdout={} stderr={}", String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr) ); out}fn id(h: &Harness) -> String { let ids: Vec<_> = fs::read_dir(h.data.join("runs")) .unwrap() .map(Result::unwrap) .filter(|e| e.file_type().unwrap().is_dir()) .collect(); assert_eq!(ids.len(), 1); ids[0].file_name().into_string().unwrap()}fn decode(h: &Harness) -> DecodedRun { let pack = hexwork_content::RuntimePack::load(&h.content).unwrap(); decode_run( &read_file_bounded( h.data.join("runs").join(id(h)).join("run.save"), MAX_COMPRESSED_BYTES, ) .unwrap(), pack.hash(), ) .unwrap()}fn loaded(h: &Harness, name: &str, body: &str) -> DecodedRun { let script = h.script(name, &format!("{body}save\nquit\n")); let mut c = h.managed(&script); c.arg("--load").arg(id(h)); ok(c); decode(h)}fn prepare(h: &Harness, mode: Mode) -> DecodedRun { assert!(!h.data.exists()); let town = hexwork_worldgen::milthollow(SEED); let floors = hexwork_worldgen::playable_deep(SEED); let town_down = town .connectors .iter() .find(|c| c.destination == hexwork_worldgen::PlanDestination::Deep { depth: 1 }) .unwrap(); let init = h.script("init.commands", "save\nquit\n"); let mut c = h.managed(&init); c.args([ "--seed", "140", "--name", "Tinker live", "--ancestry", "1", "--calling", "2", ]); if mode == Mode::Story { c.arg("--story"); } ok(c); let run_id = id(h); let town_blockers = support::sampled_route::world_blockers(&town); support::route_driver::walk_to(h, &run_id, &town.writes, &town_blockers, town_down.access); support::route_driver::step(h, &run_id, "input confirm\nsave\nquit\n"); let mut prev_down = town_down; let mut downs = Vec::new(); for depth in 1..=3usize { let f = &floors[depth - 1]; let down = f .connectors .iter() .find(|c| c.destination == hexwork_worldgen::PlanDestination::Deep { depth: (depth + 1) as u8 }) .unwrap(); let blockers = support::sampled_route::floor_blockers(f); support::route_driver::walk_to(h, &run_id, &f.writes, &blockers, down.access); support::route_driver::step(h, &run_id, "input confirm\nsave\nquit\n"); prev_down = down; downs.push(down); } let f4 = &floors[3]; let up4 = paired(&f4.connectors, prev_down); let item = f4 .items .iter() .find(|i| i.template_id == "hymn_card_blank") .unwrap(); let f4_blockers = support::sampled_route::floor_blockers(f4); support::route_driver::walk_to(h, &run_id, &f4.writes, &f4_blockers, item.position); support::route_driver::step(h, &run_id, "input pickup\ninput confirm\nsave\nquit\n"); // Ascending through a floor's up-connector lands the player at the // down-connector access (29,10) of the floor above, so each floor needs a // walk across to its up-connector access (3,10) before the next Confirm. support::route_driver::walk_to(h, &run_id, &f4.writes, &f4_blockers, up4.access); support::route_driver::step(h, &run_id, "input confirm\nsave\nquit\n"); for depth in (0..=2usize).rev() { let f = &floors[depth]; let base = if depth == 0 { town_down } else { downs[depth - 1] }; let up = paired(&f.connectors, base); let blockers = support::sampled_route::floor_blockers(f); support::route_driver::walk_to(h, &run_id, &f.writes, &blockers, up.access); support::route_driver::step(h, &run_id, "input confirm\nsave\nquit\n"); } let tinker = town .interactions .iter() .find(|i| matches!(&i.kind, hexwork_worldgen::InteractionKind::Service { id } if id == "tinker")) .unwrap(); support::route_driver::walk_to(h, &run_id, &town.writes, &town_blockers, tinker.access);
let r = decode(h); assert_eq!( r.identity.content_hash, hexwork_content::RuntimePack::load(&h.content) .unwrap() .hash() ); assert_eq!(r.identity.seed, 140); assert_eq!(r.identity.mode, mode); assert_eq!(r.identity.display_name, "Tinker live"); assert_eq!( r.identity.ancestry, hexwork_core::character::Ancestry::Mudborn ); assert_eq!( r.identity.calling, hexwork_core::character::Calling::Lampbearer ); assert_eq!( r.identity.initial_lineage, hexwork_save::InitialLineage::default() ); assert_eq!(r.identity.run_id.to_string(), id(h)); let vault = hexwork_save::decode_vault( &read_file_bounded( h.data.join("lineage-vault.json"), hexwork_save::MAX_VAULT_BYTES, ) .unwrap(), ) .unwrap(); assert_eq!(r.identity.lineage_id, vault.lineage_id); assert_eq!(r.events.len(), 1061); assert!(r.events.iter().any(|e| matches!( e.kind, SessionEventKind::Action(CommittedAction::Pickup { item_id: 43 }) ))); let p = r.current.actors.iter().find(|a| a.id == 1).unwrap(); assert!(p.alive); assert_eq!(p.hp, 10); assert_eq!(p.position.cell(), [8,19,8]); assert_eq!(r.current.wallet(1).unwrap().copper(), 25); let item = &r.current.packs[&1].items[&43]; assert_eq!(item.template, "hymn_card_blank"); assert!(!r.current.is_effectively_identified(item)); r}const OPEN: &str = "input confirm\ninput pick4\ninput confirm\n";fn purchase(h: &Harness) -> DecodedRun { loaded( h, "purchase.commands", &format!("{OPEN}input confirm\ninput confirm\n"), )}fn transaction(before: &DecodedRun, after: &DecodedRun, paid: bool, ticks: bool) { assert_eq!(after.identity, before.identity); assert_eq!( after .story_checkpoint .as_ref() .map(|c| (c.after_event_count, c.state.hash, c.state.tick)), before .story_checkpoint .as_ref() .map(|c| (c.after_event_count, c.state.hash, c.state.tick)) ); assert_eq!( &after.events[..before.events.len()], before.events.as_slice() ); let suffix = &after.events[before.events.len()..]; let buys: Vec<_> = suffix .iter() .filter(|e| { matches!( e.kind, SessionEventKind::Action(CommittedAction::TinkerIdentify { .. }) ) }) .collect(); assert_eq!(buys.len(), usize::from(paid)); for e in buys { assert!(matches!( e.kind, SessionEventKind::Action(CommittedAction::TinkerIdentify { item_id: 43 }) )); assert_eq!(e.post_tick - e.pre_tick, 20); } if !ticks { assert_eq!(suffix.len(), usize::from(paid)); assert_eq!(after.current.tick, before.current.tick); assert_eq!( after.current.to_image().unwrap().rng_master, before.current.to_image().unwrap().rng_master ); } assert_eq!(after.current.packs, before.current.packs); assert_eq!(after.current.items, before.current.items); assert_eq!(after.current.ground_items, before.current.ground_items); let mut wallets = before.current.to_image().unwrap().wallets; if paid { wallets .get_mut(&1) .unwrap() .checked_spend_copper(15) .unwrap(); } assert_eq!(after.current.to_image().unwrap().wallets, wallets); let mut idf = before.current.idf.clone(); if paid { idf.insert("hymn_card_blank".into(), 2); } assert_eq!(after.current.idf, idf); assert_eq!( after .current .is_effectively_identified(&after.current.packs[&1].items[&43]), paid );}#[test]fn tinker_generated_unknown_roundtrip() { for mode in [Mode::Story, Mode::Classic] { let h = Harness::new("tinker-generated"); let before = prepare(&h, mode); let cancelled = loaded( &h, "cancel.commands", &format!("{OPEN}input confirm\ninput escape\n"), ); transaction(&before, &cancelled, false, false); let after = purchase(&h); // A committed paid identification now spends its authored twenty ticks. transaction(&before, &after, true, true); }}#[test]fn tinker_save_reload_binary_replay() { for mode in [Mode::Story, Mode::Classic] { let h = Harness::new("tinker-replay"); let before = prepare(&h, mode); let after = purchase(&h); transaction(&before, &after, true, true); let restored = loaded(&h, "reload.commands", ""); assert_eq!(restored.identity, after.identity); assert_eq!(restored.events, after.events); assert_eq!(restored.current.wallet(1), after.current.wallet(1)); assert_eq!(restored.current.idf, after.current.idf); let hash = after.current.try_hash_state().unwrap(); assert_eq!(restored.current.try_hash_state().unwrap(), hash); // Export only the actual captured binary identity/events, never synthetic actions. let replay = ProductionReplay { identity: after.identity, events: after.events, expected_final_tick: after.current.tick, expected_final_hash: hash, }; let path = h.root.join("captured-tinker.hexdemo"); fs::write(&path, encode_replay(&replay).unwrap()).unwrap(); let mut c = h.command(); c.arg("--content-root") .arg(&h.content) .arg("--replay") .arg(&path); let out = ok(c); let text = String::from_utf8(out.stdout).unwrap(); assert!(text.contains("verified event-text replay")); assert!(text.contains(&format!("hash {hash:#x}"))); let retained = std::env::var_os("HEXWORK_TEST_EVIDENCE_DIR").map(|directory| { fs::create_dir_all(&directory).unwrap(); let directory = fs::canonicalize(directory).unwrap(); let root = fs::canonicalize(&h.root).unwrap(); assert!( !directory.starts_with(&root), "evidence destination must be outside the harness root" ); let name = match mode { Mode::Story => "captured-tinker-story.hexdemo", Mode::Classic => "captured-tinker-classic.hexdemo", Mode::Ironcartographer => unreachable!("this test covers story and classic"), }; let destination = directory.join(name); fs::copy(&path, &destination).unwrap(); assert_eq!(fs::read(&destination).unwrap(), fs::read(&path).unwrap()); destination }); let root = h.root.clone(); drop(h); assert!(!root.exists(), "normal harness cleanup must still run"); if let Some(path) = retained { assert!(path.is_file(), "capture must survive harness cleanup"); eprintln!( "retained captured replay after harness cleanup: {}", path.display() ); } else { eprintln!("temporary captured replay verified; removed by harness cleanup"); } }}#[test]#[cfg(any(target_os = "linux", target_os = "macos"))]fn tinker_pty_story_classic_remap_cancel_success() { for mode in ["story", "classic", "remapped"] { for ending in ["cancel", "commit"] { let h = Harness::new(&format!("tinker-pty-{mode}-{ending}")); let before = prepare( &h, if mode == "story" { Mode::Story } else { Mode::Classic }, ); if mode == "remapped" { let map = hexwork_term::remap::DEFAULT_KEYMAP_TOML .replace("up_left = [\"Q\", \"y\"]", "up_left = [\"y\"]") .replace("quit = [\"Esc\", \"q\"]", "quit = [\"Q\"]") .replace("confirm = [\"Enter\"]", "confirm = [\"BackTab\"]"); fs::write(h.data.join("keymap.toml"), map).unwrap(); } let mut c = Command::new("python3"); c.current_dir(&h.cwd) .arg(Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/pty/tinker_live.py")) .arg(env!("CARGO_BIN_EXE_hexwork")) .arg(&h.data) .arg(&h.content) .arg(mode) .arg(id(&h)) .arg(ending); let out = ok(c); let summary: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); assert_eq!(summary["exit"], 0); assert_eq!(summary["size"], serde_json::json!([80, 24])); assert_eq!(summary["color_sgr"], mode == "story"); let after = decode(&h); transaction(&before, &after, ending == "commit", true); let restored = loaded(&h, "pty-reload.commands", ""); assert_eq!(restored.identity, after.identity); assert_eq!(restored.events, after.events); assert_eq!( restored.current.try_hash_state().unwrap(), after.current.try_hash_state().unwrap() ); } }}