Dungeons and Compilers
- Краен срок:
- 11.01.2022 17:00
- Точки:
- 20
Срокът за предаване на решения е отминал
Zork е една от класическите компютърни игри -- намирате се в стая в подземията, избирате дали да пътувате на север, на изток, и т.н.. Стаите си имат съкровища, противници, и разнообразен flavor text. Ще напишем основите на някаква такава игра.
Първо, базовите дефиниции на типове, с които ще работим:
/// Различните грешки, които ще очакваме да върнете като резултат от някои невалидни операции.
/// Повече детайли по-долу.
///
#[derive(Debug)]
pub enum Errors {
DuplicateRoom(String),
UnknownRoom(String),
IoError(std::io::Error),
LineParseError { line_number: usize },
DirectionParseError(String),
}
/// Четирите посоки, в които може една стая да има съседи. Може да добавите още trait
/// имплементации, за да си улесните живота.
///
#[derive(Clone, Copy)]
pub enum Direction {
North,
South,
East,
West,
}
/// Една стая в подземията. Дефинира се само с име, макар че в по-интересна имплементация може да
/// държи item-и, противници...
///
pub struct Room {
pub name: String,
// Каквито други полета ви трябват
}
/// Контейнер за стаите и не само. Ще работим предимно със тази структура.
///
pub struct Dungeon {
// Каквито полета ви трябват
}
Изграждане на Dungeon
Имаме структура Dungeon
, какво да правим с нея?
impl Dungeon {
/// Конструиране на празен Dungeon, в който няма никакви стаи.
///
pub fn new() -> Self {
todo!()
}
/// Добавяне на стая към Dungeon с име `name`. Връща `Ok(())` при успех. Ако вече има стая с
/// такова име, очакваме да върнете `Errors::DuplicateRoom` с името.
///
pub fn add_room(&mut self, name: &str) -> Result<(), Errors> {
todo!()
}
/// Прочитане на дадена стая -- когато извикаме `get_room`, очакваме reference към `Room`
/// структурата с това име.
///
/// Ако няма такава стая, очакваме `Errors::UnknownRoom` с подаденото име.
///
pub fn get_room(&self, room_name: &str) -> Result<&Room, Errors> {
todo!()
}
/// Добавяне на съсед на дадена стая. След извикването на функцията, очакваме стаята с име
/// `room_name` да има връзка в посока `direction` със стаята с име `other_room_name`.
///
/// Също така очакваме `other_room_name` да има връзка с `room_name` в *обратната* посока.
///
/// Успешен резултат е `Ok(())`. В случай, че която от двете стаи не същестува, очакваме грешка
/// `Errors::UnknownRoom` със съответното име на липсваща стая. Ако и двете липсват, спокойно
/// върнете тази, която проверявате първо.
///
pub fn set_link(
&mut self,
room_name: &str,
direction: Direction,
other_room_name: &str,
) -> Result<(), Errors> {
todo!()
}
/// Четене на съседа на стаята с име `room_name` в посока `direction`. Тук има няколко
/// варианта на изход:
///
/// - Ако подадената стая не съществува, очакваме грешка `Errors::UnknownRoom`
/// - Ако подадената стая няма съсед в тази посока, Ok(None) е смисления резултат
/// - Иначе, чакаме reference към `Room` структурата на въпросния съсед, опакована в `Ok(Some(`.
///
pub fn get_next_room(&self, room_name: &str, direction: Direction) -> Result<Option<&Room>, Errors> {
todo!()
}
}
Ето едно примерно изграждане на dungeon:
let mut dungeon = Dungeon::new();
dungeon.add_room("Entrance").unwrap();
dungeon.add_room("Hallway").unwrap();
dungeon.set_link("Entrance", Direction::East, "Hallway").unwrap();
assert_eq!(dungeon.get_room("Hallway").unwrap().name, "Hallway");
assert_eq!(dungeon.get_next_room("Hallway", Direction::West).unwrap().unwrap().name, "Entrance");
Забележете какво отбелязахме по-горе -- добавянето на връзка от "Entrance" към "Hallway" на изток означава добавяне на обратната връзка на запад.
Възможно ли е да конструираме две връзки, които се overwrite-ват? Да, няма проблеми:
let mut dungeon = Dungeon::new();
dungeon.add_room("Entrance").unwrap();
dungeon.add_room("Hallway").unwrap();
dungeon.add_room("Magic Lab").unwrap();
dungeon.set_link("Entrance", Direction::East, "Hallway").unwrap();
dungeon.set_link("Hallway", Direction::West, "Magic Lab").unwrap();
assert_eq!(dungeon.get_next_room("Entrance", Direction::East).unwrap().unwrap().name, "Hallway");
assert_eq!(dungeon.get_next_room("Hallway", Direction::West).unwrap().unwrap().name, "Magic Lab");
Отиваме на изток от входа и се озоваваме в коридор. Връщаме се на запад и изведнъж сме в някаква магическа лаборатория. That's just how magic dungeons roll, go with it. Когато казваме ".set_link
добавя връзки в двете посоки", просто си го направете, пък ако по-нататъшни извиквания променят нещо, няма проблеми. Няма проблеми и .set_link
да пренапише предни връзки в процеса на работа.
Напълно възможно е и да се създаде връзка от една стая към същата стая. It's magic, може да loop-не.
(Hint: Ако се чудите как точно да съхраните данните на Room
структурите така че лесно да ги достъпвате по име, HashMap вероятно е най-лесния вариант. Низовите имена спокойно могат да репрезентират и връзките между стаи, което сигурно ще е по-лесно, отколкото да се мъчите с Rc-та и RefCell-ове примерно.)
Разбира се, дизайнерите на dungeon-а няма да им се занимава да пишат rust код, твърде много скоби и амперсанди. Нека да изпарсим структурата от прост файл.
Парсене
Ето един примерен файл, който ще искаме да можем да четем, който отговаря на горната структура:
## Rooms
- Entrance
- Hallway
- Magic Lab
## Links
- Entrance -> East -> Hallway
- Hallway -> West -> Magic Lab
Ето и структурата на функцията, която ще ползваме:
impl Dungeon {
/// Прочитаме структурата на dungeon от нещо, което имплементира `BufRead`. Това може да е
/// файл, или, ако тестваме, може да е просто колекция от байтове.
///
/// Успешен резултат връща новосъздадения dungeon, пакетиран в `Ok`.
///
/// Вижте по-долу за обяснение на грешките, които очакваме.
///
pub fn from_reader<B: BufRead>(reader: B) -> Result<Self, Errors> {
todo!()
}
}
Първия ред винаги трябва да е ## Rooms
. Иначе, върнете LineParseError
с line_number
1. След това, всеки следващ ред се очаква или да започне с -
и име на стая за добавяне (add_room
), или да е празен ред, което означава, че сме приключили с добавянето на стаи. Ако е нещо друго, LineParseError
със съответния ред (първия ред е 1).
Всякакви грешки, които могат да случат при извикване на add_room
се очаква да се върнат от тази функция ако се случат.
Всякакви std::io::Error
грешки при четене на reader-а се очаква да се пакетират във Errors::IoError
и да се върнат.
Следващия ред се очаква да бъде точно ## Links
. Иначе, очакваме LineParseError
с реда. Оттам нататък всички следващи редове трябва да имат формата:
- <име на стая> -> <посока> -> <друга стая>
Тоест, реда започва с низа "- "
, и отделните стаи са разделени с низа " -> "
. Приемете, че в имената на стаите няма да съдържат ->
и даже ->
. Дали да изчистите интервалите около името е ваш избор, няма да тестваме с имена на стаи, които започват или завършват на whitespace.
Както може да се сетите, изпарсеното съдържание на този ред си го подайте на set_link
. Ако посоката не е една от North/South/East/West, очакваме грешка Errors::DirectionParseError
с низа, който се опитвате да изпарсите в посока. Ако има каквато и да е грешка във формата на този ред, LineParseError
със съответния номер на ред.
Ако set_link
върне грешка, очакваме да я върнете от тази функция.
В случай, че на функцията се подаде празен reader, очакваме LineParseError
с номер на ред 0.
Ето прототипа на една помощна функция, която няма да тестваме директно (не е pub
, няма да я викаме в тестовете), но може да ви улесни живота:
/// match_prefix("- ", "- Foo") //=> Some("Foo")
/// match_prefix("- ", "Bar") //=> None
///
fn match_prefix<'a, 'b>(prefix: &'a str, input: &'b str) -> Option<&'b str> {
todo!()
}
Свободни сте да я ползвате или не, свободни сте да напишете различна подобна функция. Ако ви притесняват lifetime анотациите, със сигурност ви съветваме да я имплементирате и ползвате, и да прочетете "Lifetimes" лекцията, докато спрат да ви притесняват. Имаме точно такъв пример някъде из слайдовете.
Накрая, след като сме си конструирали dungeon, да направим и нещо интересно с него.
Намиране на път
Би било смислено да правим неща като намиране на път към съкровището, който заобикаля противници, или може би търсене на противници за максимален конфликт и вдигане на level-и. Разбира се, тук нямаме съкровище, противници, или level-и, така че нека напишем просто намиране на път от една стая към друга:
impl Dungeon {
/// Търси път от `start_room_name` до `end_room_name` и го връща във вектор, пакетиран във
/// `Ok(Some(` ако намери.
///
/// Ако няма път между тези две стаи, връща `Ok(None)`.
///
/// Ако четенето на стаи в един момент върне грешка, очакваме да върнете грешката нагоре.
///
pub fn find_path(
&self,
start_room_name: &str,
end_room_name: &str
) -> Result<Option<Vec<&Room>>, Errors> {
todo!()
}
}
Какъв алгоритъм да ползвате? Какъвто искате -- няма да проверяваме резултатния път директно, ще проверим:
- Дали е път -- всяка стая трябва да има директна връзка със следващата в някаква посока
- Дали началото и края са правилните стаи
Нещо лесно, което може да ползвате, е Breadth-first search. Алгоритъма е доста добре описан в уикипедия, макар че няма логика за намиране на пълния път, само се намира node. Споменават, че може да държите parents, но не показват къде пасва в кода. Ето обобщение:
- Подготвяте си опашка, в която слагате началната стая (опашката може да е вектор, а може да има и друга удачна структура в std::collections, която да ви свърши работа)
- Маркирате началната стая като "посетена" (може би във вектор от посетени стаи, а може би нещо друго от std::collections)
- Подготвяте си един
HashMap
от връзки от стая към нейния родител. - Започвате да вадите стаи от края на опашката за посещаване, докато има стаи в нея:
- Ако стигнете до крайната стая, може да приключите с цикъла
- Ако не, минавате през всички комшии на една стая, записвате че родителя им е текущата, маркирате ги като "посетени" и ги добавяте в началото на опашката.
- Приключили сме цикъла. Има ли маркиран родител за крайната стая?
- Ако не, значи не сме стигнали до крайната стая, може спокойно да върнем
Ok(None)
- Ако да, трябва да си конструираме път обратно до началото, родител по родител. Започваме от крайната стая и нейния родител, и ги пъхаме в един вектор, продължаваме да вземаме предния родител и предния родител, докато не стигнем до стая без родител -- това ще е началната. Връщаме пътя в "прав" ред -- начална до крайна стая.
- Ако не, значи не сме стигнали до крайната стая, може спокойно да върнем
Ако ви изглежда притеснително сложно -- пробвайте, карайте стъпка по стъпка, ще стане. Ако ви изглежда досадно просто, by all means имплементирайте си каквото търсене ви е интересно.
Съвети
- Има доста error handling, който може да се опрости с употреба на някои методи от
Result
или отOption
. Споменавали сме ги, но също така просто разгледайте документацията. - Нищо не пречи да търсим път от една стая към същата стая -- това е просто път с тази една стая. Също нищо учудващо няма в това да има цикъл от стаи. Ако си имплементирате горния алгоритъм, не би трябвало да имате проблеми, но определено е добра идея да тествате.
-
HashMap е ужасно удобна структура, която се използва за супер много неща и в доста езици е вградена. Това е речник, в който ключа е нещо, което имплементира
Hash
trait-а (много често еString
), а стойността може да е каквото и да е. Документацията има чудесни примери, но може и да направим едно бонус демо видео, в което показваме употреба.
Задължително прочетете (или си припомнете): Указания за предаване на домашни
Погрижете се решението ви да се компилира с базовия тест: