aboutsummaryrefslogtreecommitdiffstatshomepage
diff options
context:
space:
mode:
-rw-r--r--.gitignore3
-rw-r--r--LICENSE21
-rw-r--r--Makefile9
-rw-r--r--data/goty.tsv204
-rw-r--r--data/tr-travels.tsv345
-rw-r--r--setup.py23
-rw-r--r--ywalk.1216
-rw-r--r--ywalk/__init__.py0
-rw-r--r--ywalk/graph.py125
-rw-r--r--ywalk/main.py136
-rw-r--r--ywalk/parser.py47
-rw-r--r--ywalk/types.py46
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/
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..2149bd8
--- /dev/null
+++ b/LICENSE
@@ -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',
+ ]
+ }
+)
diff --git a/ywalk.1 b/ywalk.1
new file mode 100644
index 0000000..ca1b56d
--- /dev/null
+++ b/ywalk.1
@@ -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})'