diff options
author | Wolfgang Müller | 2021-11-14 18:55:52 +0100 |
---|---|---|
committer | Wolfgang Müller | 2021-11-14 18:55:52 +0100 |
commit | 0fb4fc20559c9a5de5a10c74c1247635a1523255 (patch) | |
tree | dbd27ffd7e5d09af732e973455bea1e47e46609f | |
download | ywalk-trunk.tar.gz |
Diffstat (limited to '')
-rw-r--r-- | .gitignore | 3 | ||||
-rw-r--r-- | LICENSE | 21 | ||||
-rw-r--r-- | Makefile | 9 | ||||
-rw-r--r-- | data/goty.tsv | 204 | ||||
-rw-r--r-- | data/tr-travels.tsv | 345 | ||||
-rw-r--r-- | setup.py | 23 | ||||
-rw-r--r-- | ywalk.1 | 216 | ||||
-rw-r--r-- | ywalk/__init__.py | 0 | ||||
-rw-r--r-- | ywalk/graph.py | 125 | ||||
-rw-r--r-- | ywalk/main.py | 136 | ||||
-rw-r--r-- | ywalk/parser.py | 47 | ||||
-rw-r--r-- | ywalk/types.py | 46 |
12 files changed, 1175 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4176048 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +*.egg-info/ +/build/ +/dist/ @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Wolfgang Müller + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..abe9d8b --- /dev/null +++ b/Makefile @@ -0,0 +1,9 @@ +PREFIX ?= ${HOME}/.local + +install: ywalk.1 install-data + install -D -m 644 -t '${DESTDIR}${PREFIX}/share/man/man1' ywalk.1 + +install-data: data/goty.tsv data/tr-travels.tsv + install -D -m 644 -t '${DESTDIR}${PREFIX}/share/ywalk' data/goty.tsv data/tr-travels.tsv + +.PHONY: install install-data diff --git a/data/goty.tsv b/data/goty.tsv new file mode 100644 index 0000000..687c3e5 --- /dev/null +++ b/data/goty.tsv @@ -0,0 +1,204 @@ +Origin Destination Mode Time +Andasreth Gnisis Almsivi Intervention 0 +Berandas Gnisis Almsivi Intervention 0 +Dagon Fel Ald'ruhn Almsivi Intervention 0 +Ebonheart Vivec Almsivi Intervention 0 +Falasmaryon Ald'ruhn Almsivi Intervention 0 +Falensarano Ald'ruhn Almsivi Intervention 0 +Fort Frostmoth Gnisis Almsivi Intervention 0 +Fort Hawkmoth Vivec Almsivi Intervention 0 +Fort Moonmoth Balmora Almsivi Intervention 0 +Gnaar Mok Balmora Almsivi Intervention 0 +Hla Oad Balmora Almsivi Intervention 0 +Holomayan Molag Mar Almsivi Intervention 0 +Indarys Manor Ald'ruhn Almsivi Intervention 0 +Indoranyon Ald'ruhn Almsivi Intervention 0 +Khuul Gnisis Almsivi Intervention 0 +Maar Gan Ald'ruhn Almsivi Intervention 0 +Marandus Balmora Almsivi Intervention 0 +Pelagiad Vivec Almsivi Intervention 0 +Pelagiad Balmora Almsivi Intervention 0 +Raven Rock Gnisis Almsivi Intervention 0 +Rethan Manor Balmora Almsivi Intervention 0 +Rotheran Ald'ruhn Almsivi Intervention 0 +Sadrith Mora Molag Mar Almsivi Intervention 0 +Seyda Neen Vivec Almsivi Intervention 0 +Suran Vivec Almsivi Intervention 0 +Tel Aruhn Molag Mar Almsivi Intervention 0 +Tel Branora Molag Mar Almsivi Intervention 0 +Tel Mora Ald'ruhn Almsivi Intervention 0 +Tel Uvirith Molag Mar Almsivi Intervention 0 +Telasero Molag Mar Almsivi Intervention 0 +Valenvaryon Gnisis Almsivi Intervention 0 +Vos Ald'ruhn Almsivi Intervention 0 +Wolverine Hall Molag Mar Almsivi Intervention 0 +Dagon Fel Khuul Boat 8 +Dagon Fel Sadrith Mora Boat 10 +Dagon Fel Tel Aruhn Boat 9 +Dagon Fel Tel Mora Boat 5 +Ebonheart Hla Oad Boat 5 +Ebonheart Sadrith Mora Boat 11 +Ebonheart Tel Branora Boat 6 +Ebonheart Vivec Boat 1 +Ebonheart Holomayan Boat 9 +Fort Frostmoth Khuul Boat 6 +Fort Frostmoth Raven Rock Boat 2 +Gnaar Mok Hla Oad Boat 4 +Gnaar Mok Khuul Boat 7 +Hla Oad Ebonheart Boat 5 +Hla Oad Gnaar Mok Boat 4 +Hla Oad Molag Mar Boat 10 +Hla Oad Vivec Boat 5 +Holomayan Ebonheart Boat 9 +Khuul Dagon Fel Boat 8 +Khuul Gnaar Mok Boat 7 +Khuul Fort Frostmoth Boat 6 +Molag Mar Hla Oad Boat 10 +Molag Mar Tel Branora Boat 2 +Molag Mar Vivec Boat 4 +Raven Rock Fort Frostmoth Boat 2 +Sadrith Mora Ebonheart Boat 11 +Sadrith Mora Dagon Fel Boat 10 +Sadrith Mora Tel Branora Boat 8 +Sadrith Mora Tel Mora Boat 5 +Tel Aruhn Dagon Fel Boat 5 +Tel Aruhn Tel Mora Boat 4 +Tel Aruhn Vos Boat 4 +Tel Branora Ebonheart Boat 6 +Tel Branora Molag Mar Boat 2 +Tel Branora Sadrith Mora Boat 8 +Tel Branora Vivec Boat 5 +Tel Mora Dagon Fel Boat 5 +Tel Mora Sadrith Mora Boat 5 +Tel Mora Tel Aruhn Boat 4 +Tel Mora Vos Boat 0 +Vivec Ebonheart Boat 1 +Vivec Hla Oad Boat 5 +Vivec Molag Mar Boat 4 +Vivec Tel Branora Boat 5 +Vos Sadrith Mora Boat 5 +Vos Tel Aruhn Boat 4 +Vos Tel Mora Boat 0 +Ald'ruhn Fort Buckmoth Divine Intervention 0 +Andasreth Fort Darius Divine Intervention 0 +Balmora Fort Moonmoth Divine Intervention 0 +Berandas Fort Darius Divine Intervention 0 +Caldera Fort Buckmoth Divine Intervention 0 +Dagon Fel Fort Buckmoth Divine Intervention 0 +Ebonheart Fort Hawkmoth Divine Intervention 0 +Falasmaryon Fort Darius Divine Intervention 0 +Falensarano Wolverine Hall Divine Intervention 0 +Gnaar Mok Fort Buckmoth Divine Intervention 0 +Gnisis Fort Darius Divine Intervention 0 +Hla Oad Fort Moonmoth Divine Intervention 0 +Hlormaren Fort Moonmoth Divine Intervention 0 +Holomayan Wolverine Hall Divine Intervention 0 +Indoranyon Wolverine Hall Divine Intervention 0 +Khuul Fort Darius Divine Intervention 0 +Maar Gan Fort Buckmoth Divine Intervention 0 +Marandus Fort Pelagiad Divine Intervention 0 +Molag Mar Wolverine Hall Divine Intervention 0 +Pelagiad Fort Pelagiad Divine Intervention 0 +Raven Rock Fort Frostmoth Divine Intervention 0 +Rethan Manor Fort Moonmoth Divine Intervention 0 +Rotheran Fort Buckmoth Divine Intervention 0 +Sadrith Mora Wolverine Hall Divine Intervention 0 +Seyda Neen Fort Pelagiad Divine Intervention 0 +Seyda Neen Fort Hawkmoth Divine Intervention 0 +Suran Fort Pelagiad Divine Intervention 0 +Suran Fort Hawkmoth Divine Intervention 0 +Tel Aruhn Wolverine Hall Divine Intervention 0 +Tel Branora Fort Hawkmoth Divine Intervention 0 +Tel Mora Wolverine Hall Divine Intervention 0 +Tel Uvirith Wolverine Hall Divine Intervention 0 +Telasero Fort Hawkmoth Divine Intervention 0 +Valenvaryon Fort Darius Divine Intervention 0 +Vivec Fort Hawkmoth Divine Intervention 0 +Vos Wolverine Hall Divine Intervention 0 +Ald'ruhn Fort Buckmoth Foot 0 +Balmora Fort Moonmoth Foot 0 +Ebonheart Fort Hawkmoth Foot 0 +Fort Buckmoth Ald'ruhn Foot 0 +Fort Buckmoth Ald'ruhn Foot 0 +Fort Darius Gnisis Foot 0 +Fort Hawkmoth Ebonheart Foot 0 +Fort Moonmoth Balmora Foot 0 +Fort Pelagiad Pelagiad Foot 0 +Gnisis Fort Darius Foot 0 +Pelagiad Fort Pelagiad Foot 0 +Sadrith Mora Wolverine Hall Foot 0 +Wolverine Hall Sadrith Mora Foot 0 +Ald'ruhn Balmora Guild Guide 0 +Ald'ruhn Caldera Guild Guide 0 +Ald'ruhn Wolverine Hall Guild Guide 0 +Ald'ruhn Vivec Guild Guide 0 +Balmora Ald'ruhn Guild Guide 0 +Balmora Caldera Guild Guide 0 +Balmora Wolverine Hall Guild Guide 0 +Balmora Vivec Guild Guide 0 +Caldera Ald'ruhn Guild Guide 0 +Caldera Balmora Guild Guide 0 +Caldera Wolverine Hall Guild Guide 0 +Caldera Vivec Guild Guide 0 +Ebonheart Mournhold Guild Guide 0 +Mournhold Ebonheart Guild Guide 0 +Vivec Ald'ruhn Guild Guide 0 +Vivec Balmora Guild Guide 0 +Vivec Caldera Guild Guide 0 +Vivec Wolverine Hall Guild Guide 0 +Wolverine Hall Ald'ruhn Guild Guide 0 +Wolverine Hall Balmora Guild Guide 0 +Wolverine Hall Caldera Guild Guide 0 +Wolverine Hall Vivec Guild Guide 0 +Andasreth Caldera Propylon Index 0 +Berandas Caldera Propylon Index 0 +Caldera Falensarano Propylon Index 0 +Caldera Telasero Propylon Index 0 +Caldera Marandus Propylon Index 0 +Caldera Hlormaren Propylon Index 0 +Caldera Andasreth Propylon Index 0 +Caldera Berandas Propylon Index 0 +Caldera Falasmaryon Propylon Index 0 +Caldera Valenvaryon Propylon Index 0 +Caldera Rotheran Propylon Index 0 +Caldera Indoranyon Propylon Index 0 +Falasmaryon Caldera Propylon Index 0 +Falensarano Caldera Propylon Index 0 +Hlormaren Caldera Propylon Index 0 +Indoranyon Caldera Propylon Index 0 +Marandus Caldera Propylon Index 0 +Rotheran Caldera Propylon Index 0 +Telasero Caldera Propylon Index 0 +Valenvaryon Caldera Propylon Index 0 +Ald'ruhn Balmora Silt Strider 4 +Ald'ruhn Gnisis Silt Strider 4 +Ald'ruhn Khuul Silt Strider 5 +Ald'ruhn Maar Gan Silt Strider 2 +Balmora Ald'ruhn Silt Strider 4 +Balmora Seyda Neen Silt Strider 3 +Balmora Suran Silt Strider 5 +Balmora Vivec Silt Strider 4 +Gnisis Ald'ruhn Silt Strider 4 +Gnisis Khuul Silt Strider 3 +Gnisis Maar Gan Silt Strider 4 +Gnisis Seyda Neen Silt Strider 11 +Khuul Ald'ruhn Silt Strider 5 +Khuul Gnisis Silt Strider 3 +Khuul Maar Gan Silt Strider 3 +Maar Gan Ald'ruhn Silt Strider 2 +Maar Gan Gnisis Silt Strider 4 +Maar Gan Khuul Silt Strider 3 +Molag Mar Suran Silt Strider 3 +Molag Mar Vivec Silt Strider 4 +Seyda Neen Balmora Silt Strider 3 +Seyda Neen Gnisis Silt Strider 11 +Seyda Neen Suran Silt Strider 4 +Seyda Neen Vivec Silt Strider 2 +Suran Balmora Silt Strider 4 +Suran Molag Mar Silt Strider 3 +Suran Seyda Neen Silt Strider 4 +Suran Vivec Silt Strider 1 +Vivec Balmora Silt Strider 4 +Vivec Molag Mar Silt Strider 4 +Vivec Seyda Neen Silt Strider 2 +Vivec Suran Silt Strider 1 diff --git a/data/tr-travels.tsv b/data/tr-travels.tsv new file mode 100644 index 0000000..d5e2a81 --- /dev/null +++ b/data/tr-travels.tsv @@ -0,0 +1,345 @@ +Origin Destination Mode Time +Aimrah Almas Thirr Almsivi Intervention 0 +Alt Bosara Akamora Almsivi Intervention 0 +Alt Bosara Fort Windmoth Almsivi Intervention 0 +Andasreth Gnisis Almsivi Intervention 0 +Bahrammu Vos Almsivi Intervention 0 +Bal Oyra Vos Almsivi Intervention 0 +Berandas Gnisis Almsivi Intervention 0 +Bosmora Sailen Almsivi Intervention 0 +Dagon Fel Vos Almsivi Intervention 0 +Ebonheart Vivec Almsivi Intervention 0 +Enamor Dayn Sailen Almsivi Intervention 0 +Falasmaryon Ald'ruhn Almsivi Intervention 0 +Falensarano Vos Almsivi Intervention 0 +Firewatch Vos Almsivi Intervention 0 +Fort Frostmoth Gnisis Almsivi Intervention 0 +Fort Hawkmoth Vivec Almsivi Intervention 0 +Fort Moonmoth Balmora Almsivi Intervention 0 +Gah Sadrith Ranyon-Ruhn Almsivi Intervention 0 +Gnaar Mok Balmora Almsivi Intervention 0 +Gorne Sailen Almsivi Intervention 0 +Helnim Ranyon-Ruhn Almsivi Intervention 0 +Hla Oad Balmora Almsivi Intervention 0 +Holomayan Molag Mar Almsivi Intervention 0 +Ildrim Akamora Almsivi Intervention 0 +Indal-ruhn Almas Thirr Almsivi Intervention 0 +Indarys Manor Ald'ruhn Almsivi Intervention 0 +Indoranyon Vos Almsivi Intervention 0 +Khuul Gnisis Almsivi Intervention 0 +Llothanis Ranyon-Ruhn Almsivi Intervention 0 +Maar Gan Ald'ruhn Almsivi Intervention 0 +Marandus Balmora Almsivi Intervention 0 +Marog Molag Mar Almsivi Intervention 0 +Meralag Akamora Almsivi Intervention 0 +Meralag Fort Umbermoth Almsivi Intervention 0 +Old Ebonheart Vivec Almsivi Intervention 0 +Pelagiad Vivec Almsivi Intervention 0 +Pelagiad Balmora Almsivi Intervention 0 +Port Telvannis Ranyon-Ruhn Almsivi Intervention 0 +Raven Rock Gnisis Almsivi Intervention 0 +Rethan Manor Balmora Almsivi Intervention 0 +Rotheran Vos Almsivi Intervention 0 +Sadas Plantation Ranyon-Ruhn Almsivi Intervention 0 +Sadrith Mora Vos Almsivi Intervention 0 +Seyda Neen Vivec Almsivi Intervention 0 +Suran Vivec Almsivi Intervention 0 +Tel Aruhn Vos Almsivi Intervention 0 +Tel Branora Molag Mar Almsivi Intervention 0 +Tel Mora Ald'ruhn Almsivi Intervention 0 +Tel Mothrivra Ranyon-Ruhn Almsivi Intervention 0 +Tel Muthada Akamora Almsivi Intervention 0 +Tel Ouada Ranyon-Ruhn Almsivi Intervention 0 +Tel Uvirith Molag Mar Almsivi Intervention 0 +Telasero Molag Mar Almsivi Intervention 0 +Valenvaryon Gnisis Almsivi Intervention 0 +Vhul Almas Thirr Almsivi Intervention 0 +Wolverine Hall Molag Mar Almsivi Intervention 0 +Almas Thirr Old Ebonheart Boat 5 +Almas Thirr Indal-ruhn Boat 1 +Alt Bosara Necrom Boat 4 +Alt Bosara Llothanis Boat 7 +Bahrammu Bal Oyra Boat 1 +Bahrammu Dagon Fel Boat 4 +Bal Oyra Firewatch Boat 4 +Bal Oyra Bahrammu Boat 1 +Dagon Fel Khuul Boat 8 +Dagon Fel Tel Mora Boat 5 +Dagon Fel Firewatch Boat 6 +Dagon Fel Bahrammu Boat 4 +Ebonheart Vivec Boat 1 +Ebonheart Holomayan Boat 9 +Ebonheart Old Ebonheart Boat 3 +Ebonheart Seyda Neen Boat 2 +Enamor Dayn Necrom Boat 9 +Enamor Dayn Gorne Boat 1 +Firewatch Bal Oyra Boat 4 +Firewatch Dagon Fel Boat 6 +Firewatch Helnim Boat 8 +Fort Frostmoth Khuul Boat 6 +Fort Frostmoth Raven Rock Boat 2 +Gah Sadrith Llothanis Boat 4 +Gnaar Mok Hla Oad Boat 4 +Gnaar Mok Khuul Boat 7 +Gorne Enamor Dayn Boat 1 +Helnim Firewatch Boat 8 +Helnim Sadrith Mora Boat 4 +Helnim Marog Boat 1 +Helnim Ildrim Boat 9 +Hla Oad Gnaar Mok Boat 4 +Hla Oad Seyda Neen Boat 3 +Holomayan Ebonheart Boat 9 +Ildrim Helnim Boat 9 +Ildrim Marog Boat 8 +Ildrim Tel Branora Boat 4 +Ildrim Old Ebonheart Boat 7 +Indal-ruhn Almas Thirr Boat 1 +Khuul Fort Frostmoth Boat 6 +Khuul Gnaar Mok Boat 7 +Khuul Dagon Fel Boat 8 +Llothanis Alt Bosara Boat 7 +Llothanis Gah Sadrith Boat 4 +Marog Sadrith Mora Boat 4 +Marog Helnim Boat 1 +Marog Tel Branora Boat 7 +Marog Ildrim Boat 8 +Molag Mar Tel Branora Boat 2 +Molag Mar Vivec Boat 4 +Necrom Alt Bosara Boat 4 +Necrom Enamor Dayn Boat 9 +Old Ebonheart Ildrim Boat 7 +Old Ebonheart Ebonheart Boat 3 +Old Ebonheart Almas Thirr Boat 5 +Raven Rock Fort Frostmoth Boat 2 +Sadrith Mora Tel Mora Boat 5 +Sadrith Mora Tel Aruhn Boat 1 +Sadrith Mora Helnim Boat 4 +Sadrith Mora Marog Boat 4 +Seyda Neen Ebonheart Boat 2 +Seyda Neen Hla Oad Boat 3 +Tel Aruhn Tel Mora Boat 4 +Tel Aruhn Vos Boat 4 +Tel Aruhn Sadrith Mora Boat 1 +Tel Branora Molag Mar Boat 2 +Tel Branora Vivec Boat 5 +Tel Branora Marog Boat 7 +Tel Branora Ildrim Boat 4 +Tel Mora Dagon Fel Boat 5 +Tel Mora Sadrith Mora Boat 5 +Tel Mora Tel Aruhn Boat 4 +Tel Mora Vos Boat 0 +Vivec Ebonheart Boat 1 +Vivec Molag Mar Boat 4 +Vivec Tel Branora Boat 5 +Vos Tel Aruhn Boat 4 +Vos Tel Mora Boat 0 +Bosmora Enamor Dayn Caravan 2 +Enamor Dayn Bosmora Caravan 2 +Akamora Fort Windmoth Divine Intervention 0 +Ald'ruhn Fort Buckmoth Divine Intervention 0 +Almas Thirr Old Ebonheart Divine Intervention 0 +Andasreth Fort Darius Divine Intervention 0 +Bahrammu Bal Oyra Divine Intervention 0 +Balmora Fort Moonmoth Divine Intervention 0 +Berandas Fort Darius Divine Intervention 0 +Bosmora Fort Umbermoth Divine Intervention 0 +Caldera Fort Buckmoth Divine Intervention 0 +Dagon Fel Bal Oyra Divine Intervention 0 +Ebonheart Fort Hawkmoth Divine Intervention 0 +Enamor Dayn Fort Umbermoth Divine Intervention 0 +Falasmaryon Fort Darius Divine Intervention 0 +Falensarano Wolverine Hall Divine Intervention 0 +Gah Sadrith Helnim Divine Intervention 0 +Gnaar Mok Fort Buckmoth Divine Intervention 0 +Gnisis Fort Darius Divine Intervention 0 +Gorne Fort Umbermoth Divine Intervention 0 +Hla Oad Fort Moonmoth Divine Intervention 0 +Hlormaren Fort Moonmoth Divine Intervention 0 +Holomayan Wolverine Hall Divine Intervention 0 +Ildrim Cephorad Keep Divine Intervention 0 +Indal-ruhn Old Ebonheart Divine Intervention 0 +Indoranyon Firewatch Divine Intervention 0 +Khuul Fort Darius Divine Intervention 0 +Llothanis Helnim Divine Intervention 0 +Maar Gan Fort Buckmoth Divine Intervention 0 +Marandus Fort Pelagiad Divine Intervention 0 +Marog Helnim Divine Intervention 0 +Molag Mar Wolverine Hall Divine Intervention 0 +Necrom Fort Umbermoth Divine Intervention 0 +Pelagiad Fort Pelagiad Divine Intervention 0 +Port Telvannis Helnim Divine Intervention 0 +Ranyon-Ruhn Firewatch Divine Intervention 0 +Raven Rock Fort Frostmoth Divine Intervention 0 +Rethan Manor Fort Moonmoth Divine Intervention 0 +Rotheran Firewatch Divine Intervention 0 +Sadas Plantation Bal Oyra Divine Intervention 0 +Sadrith Mora Wolverine Hall Divine Intervention 0 +Sailen Fort Umbermoth Divine Intervention 0 +Seyda Neen Fort Pelagiad Divine Intervention 0 +Seyda Neen Fort Hawkmoth Divine Intervention 0 +Suran Fort Pelagiad Divine Intervention 0 +Suran Fort Hawkmoth Divine Intervention 0 +Tel Aruhn Wolverine Hall Divine Intervention 0 +Tel Branora Old Ebonheart Divine Intervention 0 +Tel Mora Firewatch Divine Intervention 0 +Tel Mothrivra Helnim Divine Intervention 0 +Tel Muthada Cephorad Keep Divine Intervention 0 +Tel Ouada Bal Oyra Divine Intervention 0 +Tel Uvirith Wolverine Hall Divine Intervention 0 +Telasero Fort Hawkmoth Divine Intervention 0 +Valenvaryon Fort Darius Divine Intervention 0 +Vhul Old Ebonheart Divine Intervention 0 +Vhul Old Ebonheart Divine Intervention 0 +Vivec Fort Hawkmoth Divine Intervention 0 +Vos Firewatch Divine Intervention 0 +Ald'ruhn Fort Buckmoth Foot 0 +Balmora Fort Moonmoth Foot 0 +Cephorad Keep Tel Muthada Foot 0 +Ebonheart Fort Hawkmoth Foot 0 +Fort Buckmoth Ald'ruhn Foot 0 +Fort Buckmoth Ald'ruhn Foot 0 +Fort Darius Gnisis Foot 0 +Fort Hawkmoth Ebonheart Foot 0 +Fort Moonmoth Balmora Foot 0 +Fort Pelagiad Pelagiad Foot 0 +Fort Umbermoth Sailen Foot 0 +Fort Windmoth Alt Bosara Foot 1 +Gnisis Fort Darius Foot 0 +Pelagiad Fort Pelagiad Foot 0 +Sadrith Mora Wolverine Hall Foot 0 +Tel Muthada Cephorad Keep Foot 0 +Wolverine Hall Sadrith Mora Foot 0 +Akamora Bosmora Guild Guide 0 +Akamora Old Ebonheart Guild Guide 0 +Ald'ruhn Balmora Guild Guide 0 +Ald'ruhn Caldera Guild Guide 0 +Ald'ruhn Wolverine Hall Guild Guide 0 +Ald'ruhn Vivec Guild Guide 0 +Bal Oyra Helnim Guild Guide 0 +Bal Oyra Firewatch Guild Guide 0 +Balmora Ald'ruhn Guild Guide 0 +Balmora Caldera Guild Guide 0 +Balmora Wolverine Hall Guild Guide 0 +Balmora Vivec Guild Guide 0 +Bosmora Akamora Guild Guide 0 +Bosmora Old Ebonheart Guild Guide 0 +Caldera Ald'ruhn Guild Guide 0 +Caldera Balmora Guild Guide 0 +Caldera Wolverine Hall Guild Guide 0 +Caldera Vivec Guild Guide 0 +Ebonheart Mournhold Guild Guide 0 +Firewatch Helnim Guild Guide 0 +Firewatch Bal Oyra Guild Guide 0 +Firewatch Vivec Guild Guide 0 +Firewatch Old Ebonheart Guild Guide 0 +Gah Sadrith Port Telvannis Guild Guide 0 +Helnim Bal Oyra Guild Guide 0 +Helnim Firewatch Guild Guide 0 +Mournhold Ebonheart Guild Guide 0 +Old Ebonheart Vivec Guild Guide 0 +Old Ebonheart Bosmora Guild Guide 0 +Old Ebonheart Akamora Guild Guide 0 +Old Ebonheart Firewatch Guild Guide 0 +Vivec Ald'ruhn Guild Guide 0 +Vivec Balmora Guild Guide 0 +Vivec Caldera Guild Guide 0 +Vivec Wolverine Hall Guild Guide 0 +Vivec Firewatch Guild Guide 0 +Vivec Old Ebonheart Guild Guide 0 +Vivec Old Ebonheart Guild Guide 0 +Wolverine Hall Ald'ruhn Guild Guide 0 +Wolverine Hall Balmora Guild Guide 0 +Wolverine Hall Caldera Guild Guide 0 +Wolverine Hall Vivec Guild Guide 0 +Andasreth Caldera Propylon Index 0 +Berandas Caldera Propylon Index 0 +Caldera Falensarano Propylon Index 0 +Caldera Telasero Propylon Index 0 +Caldera Marandus Propylon Index 0 +Caldera Hlormaren Propylon Index 0 +Caldera Andasreth Propylon Index 0 +Caldera Berandas Propylon Index 0 +Caldera Falasmaryon Propylon Index 0 +Caldera Valenvaryon Propylon Index 0 +Caldera Rotheran Propylon Index 0 +Caldera Indoranyon Propylon Index 0 +Falasmaryon Caldera Propylon Index 0 +Falensarano Caldera Propylon Index 0 +Hlormaren Caldera Propylon Index 0 +Indoranyon Caldera Propylon Index 0 +Marandus Caldera Propylon Index 0 +Rotheran Caldera Propylon Index 0 +Telasero Caldera Propylon Index 0 +Valenvaryon Caldera Propylon Index 0 +Alt Bosara Llothanis River Strider 7 +Alt Bosara Tel Mothrivra River Strider 3 +Gah Sadrith Port Telvannis River Strider 1 +Llothanis Tel Ouada River Strider 7 +Llothanis Port Telvannis River Strider 5 +Llothanis Alt Bosara River Strider 7 +Port Telvannis Tel Ouada River Strider 8 +Port Telvannis Llothanis River Strider 5 +Port Telvannis Gah Sadrith River Strider 1 +Port Telvannis Sadas Plantation River Strider 4 +Sadas Plantation Port Telvannis River Strider 4 +Tel Mothrivra Alt Bosara River Strider 3 +Tel Ouada Llothanis River Strider 6 +Tel Ouada Port Telvannis River Strider 8 +Aimrah Vhul Silt Strider 5 +Aimrah Almas Thirr Silt Strider 4 +Akamora Sailen Silt Strider 4 +Akamora Tel Muthada Silt Strider 3 +Akamora Necrom Silt Strider 6 +Ald'ruhn Balmora Silt Strider 4 +Ald'ruhn Gnisis Silt Strider 4 +Ald'ruhn Khuul Silt Strider 5 +Ald'ruhn Maar Gan Silt Strider 2 +Almas Thirr Vhul Silt Strider 2 +Almas Thirr Aimrah Silt Strider 4 +Balmora Ald'ruhn Silt Strider 4 +Balmora Seyda Neen Silt Strider 3 +Balmora Suran Silt Strider 5 +Balmora Vivec Silt Strider 4 +Bosmora Meralag Silt Strider 5 +Bosmora Sailen Silt Strider 7 +Gnisis Ald'ruhn Silt Strider 4 +Gnisis Khuul Silt Strider 3 +Gnisis Maar Gan Silt Strider 4 +Gnisis Seyda Neen Silt Strider 11 +Helnim Tel Muthada Silt Strider 3 +Helnim Ranyon-Ruhn Silt Strider 5 +Khuul Ald'ruhn Silt Strider 5 +Khuul Gnisis Silt Strider 3 +Khuul Maar Gan Silt Strider 3 +Maar Gan Ald'ruhn Silt Strider 2 +Maar Gan Gnisis Silt Strider 4 +Maar Gan Khuul Silt Strider 3 +Meralag Vhul Silt Strider 7 +Meralag Bosmora Silt Strider 5 +Molag Mar Suran Silt Strider 3 +Molag Mar Vivec Silt Strider 4 +Necrom Sailen Silt Strider 3 +Necrom Akamora Silt Strider 6 +Ranyon-Ruhn Helnim Silt Strider 5 +Ranyon-Ruhn Tel Ouada Silt Strider 3 +Sailen Bosmora Silt Strider 7 +Sailen Necrom Silt Strider 3 +Sailen Akamora Silt Strider 4 +Seyda Neen Balmora Silt Strider 3 +Seyda Neen Gnisis Silt Strider 11 +Seyda Neen Suran Silt Strider 4 +Seyda Neen Vivec Silt Strider 2 +Suran Balmora Silt Strider 4 +Suran Molag Mar Silt Strider 3 +Suran Seyda Neen Silt Strider 4 +Suran Vivec Silt Strider 1 +Tel Muthada Akamora Silt Strider 3 +Tel Muthada Helnim Silt Strider 3 +Tel Ouada Ranyon-Ruhn Silt Strider 3 +Vhul Meralag Silt Strider 7 +Vhul Aimrah Silt Strider 5 +Vhul Almas Thirr Silt Strider 2 +Vivec Balmora Silt Strider 4 +Vivec Molag Mar Silt Strider 4 +Vivec Seyda Neen Silt Strider 2 +Vivec Suran Silt Strider 1 diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..a576a12 --- /dev/null +++ b/setup.py @@ -0,0 +1,23 @@ +import setuptools + +setuptools.setup( + name="ywalk", + version="0.1.0", + author="Wolfgang Müller", + author_email="wolf@oriole.systems", + description="A fast-travel companion for The Elder Scrolls III: Morrowind", + url="https://git.oriole.systems/ywalk", + classifiers = [ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + ], + packages=setuptools.find_packages(), + python_requires=">=3.8", + + entry_points = { + 'console_scripts': [ + 'ywalk = ywalk.main:main', + ] + } +) @@ -0,0 +1,216 @@ +.Dd November 14, 2021 +.Dt YWALK 1 +.Os +.Sh NAME +.Nm ywalk +.Nd a fast-travel companion for The Elder Scrolls III: Morrowind +.Sh SYNOPSIS +.Nm +.Op Fl T +.Op Fl m Ar modes +.Op Fl M Ar modes +.Op Fl P Ar place +.Op Fl w Ar weighting_mode +.Op Ar place ... +.Nm +.Op Fl h +.Sh DESCRIPTION +.Nm +is a fast-travel companion for The Elder Scrolls III: Morrowind. +It can print out fast-travel information for a given place, or calculate +the quickest fast-travel route between a set of places. +.Pp +Place names are as they appear in the game, for example +.Dq Sadrith Mora +or +.Dq Ald'ruhn . +.Pp +If no +.Ar place +is given, +.Nm +lists all places in its database. +If only one +.Ar place +is given, the program will print out direct connections to and from that +place. +Additionally, a reachability map is printed, showing how easy it is to +travel from the given place to any other on the map. +.Pp +If two or more +.Ar places +are given, +.Nm +will calculate the best route through all of them. +.Pp +The options are as follows: +.Bl -tag -width Ds +.It Fl h +Shows the integrated help page and exits. +.It Fl m Ar modes +Allows only the use of the given modes of transport, separated by +commas. +The modes of transport are as follows: +.Pp +.TS +tab(/); +l |l |l +lb |l |l. +Value/Name/Notes += +almsivi/Almsivi Intervention/ +boat/Boat/ +caravan/Caravan/Only in Tamriel Rebuilt +divine/Divine Intervention/ +foot/Walking/Used to access Imperial Forts +guide/Guild Guide/ +propylon/Propylon Chambers/Includes the Master Index +recall/Recall/ +river_strider/River Strider/Only in Tamriel Rebuilt +silt_strider/Silt Strider/ +.TE +.It Fl M Ar modes +Avoids the use of the given modes of transport, separated by commas. +.It Fl P Ar place +Avoids traveling through the given place. +This option can be given multiple times. +.It Fl T +Avoids using modes of travel that are based on teleportation. +Specifically this excludes the Guild Guide, any Propylon-based travel, +and the Recall and Intervention spells. +This mode is useful if +.Nm +should find a path that is suitable for escort missions. +.It Fl w Ar weighting_method +Uses the given weighting method when determining the best path. +The weighting methods are as follows: +.Bl -tag -width Ds +.It Sy least-hop +Optimizes for the smallest number of hops between places. +.It Sy least-time +Optimizes for the quickest journey measured by in-game hours. +.El +.Pp +This option overrides any method given in the configuration file. +.El +.Sh CONFIGURATION +Configuration is done using a configuration file. +The file format consists of key-value pairs collected in groups: +.Bd -literal -offset indent +[misc] +data = tr-travels +recall = Tel Uvirith +.Ed +.Pp +The options for the +.Em misc +group are as follows: +.Bl -tag -width Ds +.It Sy data +The base name of the data file to use when building the travel network. +See +.Sx DATA FILES +for more information on the data file format and location. +.Pp +The default installation includes the following data files: +.Bl -tag -width Ds -offset indent +.It Sy goty +Definitions for the vanilla Game of the Year edition of Morrowind. +.It Sy tr-travels +Definitions for the Game of the Year edition of Morrowind including +Tamriel Rebuilt's +.Sy TR_Travels +plugin. +.El +.Pp +The default is +.Sy goty . +.It Sy recall +Specifies which place is accessible through the Recall spell. +This should be one of the places defined in the data file. +.Pp +By default no Recall destination is set. +.It Sy weighting_method +Specifies which weighting method to use. +See the +.Fl w +option for details. +.Pp +The default is +.Sy least-time . +.El +.Sh DATA FILES +.Nm +constructs its travel network by reading connection definitions from a +text file. +A connection definition consists of the origin and destination place, +the mode of transport, and the amount of in-game hours needed to make +the journey. +.Pp +The first line of a data file defines the column names. +Values are separated by the tab character. +.Nm +expects the following column names to be available: +.Bl -tag -width Ds +.It Sy Origin +The origin of a connection. +.It Sy Destination +The destination of a connection. +.It Sy Mode +The mode of transport, given by its full name. +.It Sy Time +The amount of in-game hours needed to complete the journey. +.El +.Pp +The following lines are the connection definitions, with values +separated by the tab character. +.Sh FILES +.Bl -tag -width Ds +.It Pa $XDG_CONFIG_HOME/ywalk/config +The configuration file for +.Nm . +.It Pa $XDG_DATA_HOME/ywalk +The base directory for data files making up the travel network. +.El +.Pp +.Nm +adheres to the XDG Base Directory Specification. +.Pp +If $XDG_CONFIG_HOME is unset or empty, it will default to +.Pa ~/.config . +.Pp +If $XDG_DATA_HOME is unset or empty, it will default to +.Pa ~/.local/share . +.Sh EXAMPLES +To print information about travel options in Vivec: +.Pp +.Dl ywalk Vivec | less +.Pp +To print the shortest path between Vivec and Gnisis: +.Pp +.Dl ywalk Vivec Gnisis +.Pp +To exclude the use of Almsivi and Divine intervention: +.Pp +.Dl ywalk -M almsivi,divine Vivec Gnisis +.Pp +To exclude any teleportation-based transport: +.Pp +.Dl ywalk -T Vivec Gnisis +.Pp +To add an intermediate stop in Vos: +.Pp +.Dl ywalk -T Vivec Vos Gnisis +.Pp +To avoid Ebonheart on the way: +.Pp +.Dl ywalk -T -P Ebonheart Vivec Vos Gnisis +.Pp +To make sure to use the least amount of hops: +.Pp +.Dl ywalk -T -P Ebonheart -w least-hop Vivec Vos Gnisis +.Sh AUTHORS +.An -nosplit +.Nm +was written by +.An Wolfgang Müller Aq Mt wolf@oriole.systems diff --git a/ywalk/__init__.py b/ywalk/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/ywalk/__init__.py diff --git a/ywalk/graph.py b/ywalk/graph.py new file mode 100644 index 0000000..18556cd --- /dev/null +++ b/ywalk/graph.py @@ -0,0 +1,125 @@ +import math + +from collections import deque +from ywalk.types import Connection, Mode + +class Graph: + WEIGHTS = { + 'least-hop': lambda c: 1, + 'least-time': lambda c: c.time + c.mode.weight, + } + + def __init__(self): + self.connections = {} + self.nodes = 0 + self.edges = 0 + self.predicates = [] + + self.set_weight() + + def add_predicate(self, predicate): + self.predicates.append(predicate) + + def set_weight(self, spec=None): + self.weight_method = Graph.WEIGHTS[spec or 'least-time'] + + def add_connection(self, conn: Connection): + for place in [conn.origin, conn.destination]: + if place not in self.connections: + self.nodes = self.nodes + 1 + self.connections[place] = set() + + if conn not in self.connections[conn.origin]: + self.edges = self.edges + 1 + self.connections[conn.origin].add(conn) + + def add_recall(self, destination): + for origin in self.get_places(): + if origin == destination: + continue + self.add_connection(Connection(origin, destination, Mode.RECALL, 0)) + + def __contains__(self, place): + return place in self.connections.keys() + + def get_connections_from(self, place): + for conn in self.connections[place]: + yield conn + + def get_connections_to(self, place): + for origin in self.get_places(): + yield from self.get_connections_between(origin, place) + + def get_connections_between(self, origin, destination): + for conn in self.connections[origin]: + if conn.destination == destination: + yield conn + + def get_places(self): + return self.connections.keys() + + def populate(self, gen): + for conn in gen: + self.add_connection(conn) + + def shortest_paths(self, origin, destination=None): + tentative = list(self.get_places()) + weights = {place: math.inf for place in self.get_places()} + hops = {} + + weights[origin] = 0 + + while tentative: + current = min(tentative, key=weights.get) + + tentative.remove(current) + + # Exit early if we were given a destination and have reached it + if destination and current == destination: + return weights, hops + + for conn in self.connections[current]: + if conn.destination not in tentative: + continue + + next_hop = conn.destination + + # Make sure to filter out any unwanted connections by setting + # their weight to infinity + if all(f(conn) for f in self.predicates): + alt_weights = weights[current] + self.weight_method(conn) + else: + alt_weights = math.inf + + if alt_weights < weights[next_hop]: + weights[next_hop] = alt_weights + hops[next_hop] = conn + + return weights, hops + + def find_path(self, *args): + stops = deque(args) + current_stop = stops.popleft() + + journey = [] + while stops: + next_stop = stops.popleft() + _, hops = self.shortest_paths(current_stop, next_stop) + + if not hops or next_stop not in hops: + return None + + path = deque() + last_hop = next_stop + + while last_hop in hops: + conn = hops[last_hop] + path.appendleft(conn) + last_hop = conn.origin + + if path is None: + return None + + journey.extend(path) + current_stop = next_stop + return journey diff --git a/ywalk/main.py b/ywalk/main.py new file mode 100644 index 0000000..3ea4145 --- /dev/null +++ b/ywalk/main.py @@ -0,0 +1,136 @@ +import argparse +import configparser +import math +import os +import sys +import ywalk.parser as Parser + +from ywalk.types import Mode, Place +from ywalk.graph import Graph + +class ParseModeRestriction(argparse.Action): + def __call__(self, parser, namespace, args, option_string=None): + arglist = set(args.split(',')) + + for arg in arglist: + if arg.upper() not in Mode.__members__: + errx(f'Not a valid Mode: {arg}') + + modes = [Mode[arg.upper()] for arg in arglist] + + saved = getattr(namespace, self.dest) or [] + saved.extend(modes) + setattr(namespace, self.dest, saved) + +# pylint: disable=line-too-long +cli = argparse.ArgumentParser() +cli.add_argument('place', type=Place, nargs='*', help='a place in Morrowind') +cli.add_argument('-w', dest='weight_spec', metavar='WEIGHTING_METHOD', help='the weighting method to apply when searching for a route') +cli.add_argument('-P', dest='deny_places', type=Place, action='append', metavar='PLACE', help='avoid traveling through this place') +cli.add_argument('-m', dest='allow_modes', action=ParseModeRestriction, metavar='MODES', help='allow only these modes of travel') +cli.add_argument('-M', dest='deny_modes', action=ParseModeRestriction, metavar='MODES', help='deny these modes of travel') +cli.add_argument('-T', dest='deny_teleport', action='store_true', help='deny teleportation') + +def errx(msg): + sys.exit(f'ywalk: {msg}') + +def pretty_print_path(path): + origin = path[0].origin + destination = path[-1].destination + + time = 0 + print(f'Start in {origin}') + for conn in path: + time = time + conn.time + + print(f' then {conn.mode.pretty} to {conn.destination}', end='') + if conn.time: + print(f' ({conn.time} {"hour" if conn.time == 1 else "hours"})') + else: + print() + + if time: + print(f'Arrive in {destination} after {time} hours.') + +def pretty_print_place(place, graph): + def pretty_print_connections(conns): + for conn in sorted(conns, key=lambda c: c.mode.value): + print(conn) + + print(f'Direct connections from {place}:') + pretty_print_connections(graph.get_connections_from(place)) + print() + + print(f'Direct connections to {place}:') + pretty_print_connections(graph.get_connections_to(place)) + + print() + print ('Reachability map:') + weights, _ = graph.shortest_paths(place) + for destination in sorted(weights.keys(), key=weights.get): + if place == destination: + continue + + reachability = "unreachable" if weights[destination] == math.inf else weights[destination] + print(f'{destination}: {reachability}') + +def get_xdg_home(xdg_type, fallback): + env = f'XDG_{xdg_type}_HOME' + if env in os.environ: + return os.environ[env] + return os.path.expanduser(fallback) + +def apply_predicates(args, graph): + if args.allow_modes: + graph.add_predicate(lambda c: c.mode in args.allow_modes) + + if args.deny_modes: + graph.add_predicate(lambda c: c.mode not in args.deny_modes) + + if args.deny_teleport: + graph.add_predicate(lambda c: not c.mode.teleport) + + if args.deny_places: + graph.add_predicate(lambda c: c.destination not in args.deny_places) + +def main(): + args = cli.parse_intermixed_args() + + config = configparser.ConfigParser() + config.read(os.path.join(get_xdg_home('CONFIG', '~/.config'), 'ywalk', 'config')) + + datafile = config.get('misc', 'data', fallback='goty') + datapath = os.path.join(get_xdg_home('DATA', '~/.local/share'), 'ywalk', f'{datafile}.tsv') + + graph = Graph() + + try: + graph.populate(Parser.parse_tsv(datapath)) + except (FileNotFoundError, Parser.InputError, ValueError) as err: + errx(err) + + recall = config.get('misc', 'recall', fallback=None) + if recall: + graph.add_recall(Place(recall)) + + for place in args.place: + if place not in graph: + errx(f'Can\'t find "{place}".') + + graph.set_weight(args.weight_spec or config.get('misc', 'weighting_method', fallback=None)) + apply_predicates(args, graph) + + if not args.place: + for place in sorted(graph.get_places(), key=lambda p: p.name): + print(place) + elif len(args.place) == 1: + pretty_print_place(args.place[0], graph) + elif len(args.place) > 1: + path = graph.find_path(*args.place) + if path is None: + errx(f'Graph exhausted, no connection between {", ".join(map(str, args.place))}') + + pretty_print_path(path) + +if __name__ == '__main__': + main() diff --git a/ywalk/parser.py b/ywalk/parser.py new file mode 100644 index 0000000..40d5f88 --- /dev/null +++ b/ywalk/parser.py @@ -0,0 +1,47 @@ +import csv + +from ywalk.types import Connection, Mode, Place + +FIELD_NAMES = ['Origin', 'Destination', 'Mode', 'Time'] + +class InputError(Exception): + def __init__(self, message, file, line, context): + super().__init__(message) + self.message = message + self.file = file + self.line = line + self.context = context + + def __str__(self): + return f'{self.file}:{self.line}: {self.message}\n {self.context}' + +def parse_row(row): + for field in FIELD_NAMES: + if row[field] is None: + raise ValueError(f'Empty field \'{field}\'') + + # This is an enum lookup, not a creation. The Enum class replaces a custom + # __new__ after class creation with one that only does lookups. + # pylint: disable=no-value-for-parameter + mode = Mode(row['Mode']) + time = int(row['Time']) + + if time < 0: + raise ValueError('Negative travel time') + + return Connection(Place(row['Origin']), Place(row['Destination']), mode, time) + +def check_fieldnames(names): + for name in FIELD_NAMES: + if name not in names: + raise ValueError(f'Missing field \'{name}\' in {names}') + +def parse_tsv(path): + with open(path, encoding='utf-8', newline='') as file: + reader = csv.DictReader(file, dialect=csv.excel_tab) + check_fieldnames(reader.fieldnames) + for line, row in enumerate(reader): + try: + yield parse_row(row) + except ValueError as err: + raise InputError(err, path, line + 1, row) from err diff --git a/ywalk/types.py b/ywalk/types.py new file mode 100644 index 0000000..33048cd --- /dev/null +++ b/ywalk/types.py @@ -0,0 +1,46 @@ +from dataclasses import dataclass +from enum import Enum + +class Mode(Enum): + ALMSIVI = 'Almsivi Intervention', 'invoke Almsivi Intervention', True, 0.25 + BOAT = 'Boat', 'take the Boat', False, 0 + CARAVAN = 'Caravan', 'use the Caravan', False, 0 + DIVINE = 'Divine Intervention', 'invoke Divine Intervention', True, 0.25 + FOOT = 'Foot', 'walk', False, 0.75 + GUIDE = 'Guild Guide', 'take the Guild Guide', True, 0.5 + PROPYLON = 'Propylon Index', 'travel by Propylon', True, 0.5 + RECALL = 'Recall', 'recall', True, 0 + RIVER_STRIDER = 'River Strider', 'take the River Strider', False, 0 + SILT_STRIDER = 'Silt Strider', 'take the Silt Strider', False, 0 + + def __new__(cls, value, pretty, teleport, weight): + obj = object.__new__(cls) + obj._value_ = value + obj.pretty = pretty + obj.weight = weight + obj.teleport = teleport + return obj + + def __str__(self) -> str: + return self.value + +@dataclass(frozen=True) +class Place: + name: str + + def __str__(self) -> str: + return self.name + +@dataclass(frozen=True) +class Connection: + origin: Place + destination: Place + mode: Mode + time: int + + def __str__(self) -> str: + time = 'instantaneous' + if self.time > 0: + time = f'{self.time} {"hour" if self.time == 1 else "hours"}' + + return f'{self.origin} -> {self.destination} ({self.mode}, {time})' |