chiark / gitweb /
angles: Reinstate compatibility with mf1 format
[otter.git] / src / spec.rs
1 // Copyright 2020-2021 Ian Jackson and contributors to Otter
2 // SPDX-License-Identifier: AGPL-3.0-or-later
3 // There is NO WARRANTY.
4
5 // game specs
6
7 use crate::crates::*;
8 use crate::outline::*;
9 use otter_support::crates::*;
10 use otter_base::crates::*;
11
12 use std::borrow::Cow;
13 use std::collections::hash_map::HashMap;
14 use std::collections::hash_set::HashSet;
15 use std::fmt::Debug;
16 use std::hash::Hash;
17 use std::convert::TryFrom;
18 use std::time::Duration;
19
20 use const_default::ConstDefault;
21 use enum_map::Enum;
22 use fehler::{throw,throws};
23 use index_vec::{define_index_type, IndexVec};
24 use num_derive::{FromPrimitive, ToPrimitive};
25 use serde::{Deserialize, Serialize};
26 use strum::{Display, EnumIter, EnumString};
27 use thiserror::Error;
28
29 use otter_base::geometry::{Coord,Pos,CoordinateOverflow};
30 use otter_base::hformat_as_display;
31
32 use crate::accounts::AccountName;
33 use crate::error::UnsupportedColourSpec;
34 use crate::gamestate::PieceSpec;
35 use crate::materials_format;
36 use crate::prelude::default;
37 use crate::utils::SVGSizeError;
38
39 pub use imp::PlayerAccessSpec;
40
41 type SpE = SpecError;
42
43 //---------- common types ----------
44
45 #[derive(Clone,Eq,PartialEq,Ord,PartialOrd,Hash,Serialize,Deserialize)]
46 #[serde(transparent)]
47 pub struct RawToken(pub String);
48
49 pub type RawFaceId = u8;
50 define_index_type! {
51   #[derive(Default,ConstDefault)]
52   pub struct FaceId = RawFaceId;
53   IMPL_RAW_CONVERSIONS = true;
54 }
55
56 #[derive(Serialize,Deserialize)]
57 #[derive(Debug,Default,Clone)]
58 #[repr(transparent)]
59 #[serde(transparent)]
60 pub struct ColourSpec(pub String);
61
62 #[derive(Debug,Default,Clone,Eq,PartialEq,Hash,Ord,PartialOrd)]
63 #[derive(Serialize,Deserialize)]
64 #[repr(transparent)]
65 #[serde(transparent)]
66 pub struct UrlSpec(pub String);
67
68 #[derive(Error,Clone,Serialize,Deserialize,Debug)]
69 pub enum SpecError {
70   #[error("improper size specification")]    ImproperSizeSpec,
71   #[error("{0}")] UnsupportedColourSpec(#[from] UnsupportedColourSpec),
72   #[error("specified face not found")]       FaceNotFound,
73   #[error("internal error: {0}")]            InternalError(String),
74   #[error("specified position is off table")] PosOffTable,
75   #[error("library not found")]              LibraryNotFound,
76   #[error("item not found in library: {0:?}")] LibraryItemNotFound(ItemSpec),
77   #[error("acl contains invalid account glob")] AclInvalidAccountGlob,
78   #[error("acl entry allow/deny overlap")]   AclEntryOverlappingAllowDeny,
79   #[error("inconsistent piece count")]       InconsistentPieceCount,
80   #[error("bad url syntax")]                 BadUrlSyntax,
81   #[error("url too long")]                   UrlTooLong,
82   #[error("compass angle invalid")]          CompassAngleInvalid,
83   #[error("piece has zero faces")]           ZeroFaces,
84   #[error("inconsistent face/edge colours")] InconsistentFacesEdgecoloursCount,
85   #[error("specified with of edges, but no edges")] SpecifiedWidthOfNoEdges,
86   #[error("shape not supported")]            UnsupportedShape,
87   #[error("negative timeout")]               NegativeTimeout,
88   #[error("complex piece where inert needed")] ComplexPieceWhereInertRequired,
89   #[error("piece alias not found")]          AliasNotFound,
90   #[error("piece alias target is multi spec")] AliasTargetMultiSpec,
91   #[error("invalid range ({0} to {1})")]     InvalidRange(usize,usize),
92   #[error("piece image/alias depth exceeded")] ImageOrAliasLoop,
93   #[error("invalid size scale")]             InvalidSizeScale,
94   #[error("multiple faces required")]        MultipleFacesRequired,
95   #[error("far too many faces ({0})")]       FarTooManyFaces(usize),
96   #[error("currency quantity not multiple of minimum unit")]
97   CurrencyQtyNotMultipleOfUnit,
98   #[error("coordinate overflow")]
99   CoordinateOverflow(#[from] CoordinateOverflow),
100   #[error("SVG handling error: {item_name} for {item_for_lib} {item_for_item} {error}")] SVGError {
101     item_name: String,
102     item_for_lib: String,
103     item_for_item: String,
104     error: SVGSizeError,
105   },
106   #[error("image for supposedly-occultable piece \
107            is not itself occultable but has multiple faces")]
108   UnoccultableButRichImageForOccultation,
109   #[error("timeout too large ({got:?} > {max:?})")]
110   TimeoutTooLarge {
111     got: Duration,
112     max: Duration,
113   },
114   #[error("wrong number of faces {got_why}={got} != {exp_why}={exp}")]
115   WrongNumberOfFaces {
116     got: RawFaceId,
117     exp: RawFaceId,
118     got_why: Cow<'static, str>,
119     exp_why: Cow<'static, str>,
120   },
121 }
122
123 //---------- Bundle "otter.toml" file ----------
124
125 #[derive(Debug,Clone,Serialize,Deserialize)]
126 pub struct BundleMeta {
127   pub title: String,
128   #[serde(default, rename="format")]
129   pub mformat: materials_format::Version,
130 }
131
132 //---------- Table TOML file ----------
133
134 #[derive(Debug,Serialize,Deserialize)]
135 pub struct TableSpec {
136   #[serde(default)] pub players: Vec<TablePlayerSpec>,
137   pub player_perms: Option<HashSet<TablePermission>>,
138   #[serde(default)] pub acl: Acl<TablePermission>,
139   #[serde(default)] pub links: HashMap<LinkKind, UrlSpec>,
140 }
141
142 #[derive(Debug,Serialize,Deserialize)]
143 #[serde(rename_all="snake_case")]
144 pub enum TablePlayerSpec {
145   Account(AccountName),
146   AccountGlob(String),
147   Local(String),
148   SameScope,
149   AllLocal,
150 }
151
152 pub type RawAcl<Perm> = Vec<AclEntry<Perm>>;
153
154 #[derive(Debug,Clone)]
155 #[derive(Deserialize)]
156 #[serde(try_from="RawAcl<Perm>")]
157 pub struct Acl<Perm: Eq + Hash> { pub ents: RawAcl<Perm> }
158
159 #[derive(Debug,Clone,Serialize,Deserialize)]
160 pub struct AclEntry<Perm: Eq + Hash> {
161   pub account_glob: String, // checked
162   pub allow: HashSet<Perm>,
163   pub deny: HashSet<Perm>,
164 }
165
166 #[derive(Debug,Clone,Copy,Serialize,Deserialize)]
167 #[derive(Hash,Eq,PartialEq,Ord,PartialOrd)]
168 #[derive(FromPrimitive,ToPrimitive,EnumIter)]
169 pub enum TablePermission {
170   TestExistence,
171   ShowInList,
172   ViewNotSecret,
173   Play,
174   ChangePieces,
175   UploadBundles,
176   SetLinks,
177   ClearBundles,
178   ResetOthersAccess,
179   RedeliverOthersAccess,
180   ModifyOtherPlayer,
181   Super,
182 }
183
184 #[derive(Copy,Clone,Debug,Eq,PartialEq,Ord,PartialOrd,Hash)]
185 #[derive(Enum,EnumString,Display)]
186 #[derive(Serialize,Deserialize)]
187 pub enum LinkKind {
188   Voice,
189   Info,
190 }
191 hformat_as_display!{LinkKind}
192
193 //---------- player accesses, should perhaps be in commands.rs ----------
194
195 #[derive(Debug,Serialize,Deserialize)]
196 pub struct PlayerAccessUnset;
197
198 #[derive(Debug,Serialize,Deserialize)]
199 pub struct FixedToken { pub token: RawToken }
200
201 #[derive(Debug,Serialize,Deserialize)]
202 pub struct TokenByEmail {
203   /// RFC822 recipient field syntax (therefore, ASCII)
204   pub addr: String,
205 }
206
207 #[derive(Debug,Serialize,Deserialize)]
208 pub struct UrlOnStdout;
209
210 //---------- Game TOML file ----------
211
212 #[derive(Debug,Serialize,Deserialize)]
213 pub struct GameSpec {
214   #[serde(default="imp::def_table_size")] pub table_size: Pos,
215   #[serde(default)] pub pieces: Vec<PiecesSpec>,
216   #[serde(default="imp::def_table_colour")] pub table_colour: ColourSpec,
217   #[serde(default)] pub pcaliases: HashMap<String, Box<dyn PieceSpec>>,
218   #[serde(default, rename="format")] pub mformat: materials_format::Version,
219 }
220
221 #[derive(Debug, Serialize, Deserialize)]
222 pub struct PiecesSpec {
223   pub pos: Option<Pos>,
224   pub posd: Option<Pos>,
225   pub count: Option<u32>,
226   pub face: Option<FaceId>,
227   pub pinned: Option<bool>,
228   #[serde(default)] pub angle: Option<PieceAngleSpec>,
229   #[serde(flatten)]
230   pub info: Box<dyn PieceSpec>,
231 }
232
233 #[derive(Debug,Clone,Serialize,Deserialize)]
234 #[serde(untagged)]
235 pub enum PieceAngleSpec {
236   Compass(String),
237   Degrees(i32),
238
239   CompatMF1 { #[serde(rename="Compass")] i: u8 },
240 }
241
242 #[derive(Debug,Copy,Clone,Serialize,Deserialize)]
243 pub enum PieceAngle {
244   Compass(CompassAngle),
245 }
246 impl PieceAngle {
247   pub fn is_default(&self) -> bool { match self {
248     PieceAngle::Compass(a) => *a == CompassAngle::default(),
249     #[allow(unreachable_patterns)] _ => false,
250   } }
251 }
252
253 #[derive(Debug,Default,Clone,Serialize,Deserialize)]
254 pub struct TextOptionsSpec {
255   pub colour: Option<ColourSpec>,
256   pub size: Option<f64>,
257 }
258
259 #[derive(Debug,Copy,Clone,Eq,PartialEq)]
260 #[derive(Default,Serialize,Deserialize)]
261 #[serde(try_from="u8")]
262 #[serde(into="u8")]
263 /// 0 = unrotated, +ve is anticlockwise, units of 45deg
264 pub struct CompassAngle(u8);
265
266 //---------- Piece specs ----------
267 // the implementations are in shapelib.rs and pieces.rs
268
269 #[derive(Debug,Clone,Serialize,Deserialize)]
270 pub struct ItemSpec {
271   pub lib: String,
272   pub item: String,
273 }
274
275 pub mod piece_specs {
276   use super::*;
277
278   pub type FaceColourSpecs = IndexVec<FaceId,ColourSpec>;
279
280   #[derive(Debug,Serialize,Deserialize)]
281   pub struct SimpleCommon {
282     pub itemname: Option<String>,
283     pub faces: IndexVec<FaceId, ColourSpec>,
284     #[serde(default)] pub edges: IndexVec<FaceId, ColourSpec>,
285     pub edge_width: Option<f64>,
286   }
287
288   #[derive(Debug,Serialize,Deserialize)]
289   pub struct Disc {
290     pub diam: Coord,
291     #[serde(flatten)]
292     pub common: SimpleCommon,
293   }
294
295   #[derive(Debug,Serialize,Deserialize)]
296   pub struct Rect {
297     pub size: Vec<Coord>,
298     #[serde(flatten)]
299     pub common: SimpleCommon,
300   }
301
302   #[derive(Debug,Serialize,Deserialize)]
303   pub struct Hand {
304     #[serde(flatten)] pub c: OwnedCommon,
305   }
306
307   #[derive(Debug,Serialize,Deserialize)]
308   pub struct PlayerLabel {
309     #[serde(flatten)] pub c: OwnedCommon,
310   }
311
312   #[derive(Debug,Serialize,Deserialize)]
313   pub struct OwnedCommon {
314     pub colour: ColourSpec,
315     pub edge: Option<ColourSpec>,
316     pub edge_width: Option<f64>,
317     pub shape: Outline,
318     pub label: Option<PieceLabel>,
319   }
320
321   #[derive(Debug,Clone,Serialize,Deserialize)]
322   pub struct PieceLabel {
323     #[serde(default)] pub place: PieceLabelPlace,
324     pub colour: Option<ColourSpec>,
325   }
326
327   #[derive(Debug,Copy,Clone,Serialize,Deserialize,Eq,PartialEq)]
328   pub enum PieceLabelPlace {
329     BottomLeft,        TopLeft,
330     BottomLeftOutside, TopLeftOutside,
331   }
332
333   #[derive(Debug,Serialize,Deserialize)]
334   pub struct Deck {
335     pub faces: IndexVec<FaceId, ColourSpec>,
336     #[serde(default)] pub edges: IndexVec<FaceId, ColourSpec>,
337     pub edge_width: Option<f64>,
338     pub shape: Outline,
339     pub label: Option<PieceLabel>,
340     #[serde(default)] pub stack_pos: [Coord; 2],
341   }
342 }
343
344 // ---------- Implementation - angles ----------
345
346 impl PieceAngle {
347   pub fn is_rotated(&self) -> bool { match self {
348     &PieceAngle::Compass(CompassAngle(a)) => a != 0,
349   } }
350 }
351
352 impl Default for PieceAngle {
353   fn default() -> Self { PieceAngle::Compass(default()) }
354 }
355
356 impl TryFrom<u8> for CompassAngle {
357   type Error = SpecError;
358   #[throws(SpecError)]
359   fn try_from(v: u8) -> Self {
360     if v < 8 { Self(v) }
361     else { throw!(SpE::CompassAngleInvalid) }
362   }
363 }
364
365 impl From<CompassAngle> for u8 {
366   fn from(a: CompassAngle) -> u8 {
367     a.0
368   }
369 }
370
371 //---------- Implementation ----------
372
373 pub mod imp {
374   use super::{*, SpE};
375   use crate::prelude::*;
376
377   type AS = AccountScope;
378   type TPS = TablePlayerSpec;
379
380   pub fn def_table_size() -> Pos {
381     DEFAULT_TABLE_SIZE
382   }
383   pub fn def_table_colour() -> ColourSpec {
384     ColourSpec(DEFAULT_TABLE_COLOUR.into())
385   }
386
387   impl Default for piece_specs::PieceLabelPlace {
388     fn default() -> Self { Self::BottomLeft }
389   }
390
391   impl<P: Eq + Hash> Default for Acl<P> {
392     fn default() -> Self { Acl { ents: default() } }
393   }
394
395   impl<P: Eq + Hash + Serialize> Serialize for Acl<P> {
396     fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error>
397     { self.ents.serialize(s) }
398   }
399
400   impl<P: Eq + Hash> TryFrom<RawAcl<P>> for Acl<P> {
401     type Error = SpecError;
402     #[throws(SpecError)]
403     fn try_from(ents: RawAcl<P>) -> Self {
404       for ent in &ents {
405         glob::Pattern::new(&ent.account_glob)
406           .map_err(|_| SpE::AclInvalidAccountGlob)?;
407         if ! ent.deny.is_disjoint(&ent.allow) {
408           throw!(SpE::AclEntryOverlappingAllowDeny);
409         }
410       }
411       Acl { ents }
412     }
413   }
414
415   impl loaded_acl::Perm for TablePermission {
416     type Auth = InstanceName;
417     const TEST_EXISTENCE: Self = TablePermission::TestExistence;
418     const NOT_FOUND: MgmtError = ME::GameNotFound;
419   }
420
421   impl TablePlayerSpec {
422     pub fn account_glob(&self, instance_name: &InstanceName) -> String {
423       fn scope_glob(scope: AccountScope) -> String {
424         let mut out = "".to_string();
425         scope.display_name(&[""], |s| Ok::<_,Void>(out += s)).unwrap();
426         out += "*";
427         out
428       }
429       match self {
430         TPS::Account(account) => account.to_string(),
431         TPS::AccountGlob(s) => s.clone(),
432         TPS::SameScope => scope_glob(instance_name.account.scope.clone()),
433         TPS::Local(user) => scope_glob(AS::Unix { user: user.clone() }),
434         TPS::AllLocal => {
435           // abuse that usernames are not encoded
436           scope_glob(AS::Unix { user: "*".into() })
437         }
438       }
439     }
440   }
441
442   type TDE = TokenDeliveryError;
443
444   pub fn raw_token_debug_as_str(s: &str, f: &mut fmt::Formatter)
445                                 -> fmt::Result {
446     let len = min(5, s.len() / 2);
447     write!(f, "{:?}...", &s[0..len])
448   }
449
450   impl Debug for RawToken {
451     fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
452       raw_token_debug_as_str(&self.0, f)
453     }
454   }
455
456   #[typetag::serde(tag="access")]
457   pub trait PlayerAccessSpec: Debug + Sync + Send {
458     fn override_token(&self) -> Option<&RawToken> {
459       None
460     }
461     #[throws(MgmtError)]
462     fn check_spec_permission(&self, _: Option<AuthorisationSuperuser>) {
463     }
464     fn deliver(&self,
465                ag: &AccountsGuard,
466                g: &Instance,
467                gpl: &GPlayer,
468                ipl: &IPlayer,
469                token: AccessTokenInfo)
470                -> Result<AccessTokenReport, TDE>;
471     fn describe_html(&self) -> Html {
472       let inner = Html::from_txt(&format!("{:?}", self));
473       hformat!("<code>{}</code>", inner)
474     }
475   }
476
477   #[typetag::serde]
478   impl PlayerAccessSpec for PlayerAccessUnset {
479     #[throws(TokenDeliveryError)]
480     fn deliver(&self,
481                _ag: &AccountsGuard,
482                _g: &Instance,
483                _gpl: &GPlayer,
484                _ipl: &IPlayer,
485                _token: AccessTokenInfo) -> AccessTokenReport {
486       AccessTokenReport { lines: vec![
487         "Player access not set, game not accessible to this player"
488           .to_string(),
489       ] }
490     }
491   }
492
493   #[typetag::serde]
494   impl PlayerAccessSpec for FixedToken {
495     #[throws(MgmtError)]
496     fn check_spec_permission(&self, auth: Option<AuthorisationSuperuser>) {
497       auth.ok_or(ME::SuperuserAuthorisationRequired)?
498     }
499     fn override_token(&self) -> Option<&RawToken> {
500       Some(&self.token)
501     }
502     #[throws(TokenDeliveryError)]
503     fn deliver(&self,
504                _ag: &AccountsGuard,
505                _g: &Instance,
506                _gpl: &GPlayer,
507                _ipl: &IPlayer,
508                _token: AccessTokenInfo) -> AccessTokenReport {
509       AccessTokenReport { lines: vec![ "Fixed access token".to_string() ] }
510     }
511   }
512
513   #[typetag::serde]
514   impl PlayerAccessSpec for UrlOnStdout {
515     #[throws(TDE)]
516     fn deliver<'t>(&self,
517                    _ag: &AccountsGuard,
518                    _g: &Instance,
519                    _gpl: &GPlayer,
520                    _ipl: &IPlayer,
521                    token: AccessTokenInfo)
522                    -> AccessTokenReport {
523       AccessTokenReport { lines: token.report() }
524     }
525   }
526
527   #[typetag::serde]
528   impl PlayerAccessSpec for TokenByEmail {
529     #[throws(TDE)]
530     fn deliver<'t>(&self,
531                    ag: &AccountsGuard,
532                    g: &Instance,
533                    gpl: &GPlayer,
534                    ipl: &IPlayer,
535                    token: AccessTokenInfo)
536                    -> AccessTokenReport {
537       let sendmail = &config().sendmail;
538       let mut command = Command::new(sendmail);
539
540       #[derive(Debug,Serialize)]
541       struct CommonData<'r> {
542         player_email: &'r str,
543         game_name: String,
544         nick: &'r str,
545         token_lines: Vec<String>,
546       }
547       let common = CommonData {
548         player_email: &self.addr,
549         game_name: g.name.to_string(),
550         token_lines: token.report(),
551         nick: &gpl.nick,
552       };
553
554       if self.addr.find((&['\r','\n']) as &[char]).is_some() {
555         throw!(anyhow!("email address may not contain line endings"));
556       }
557
558       let (account, _) = ag.lookup(ipl.acctid).context("find account")?;
559       let account = &account.account;
560       let message = match &account.scope {
561         AS::Unix { user } => {
562           #[derive(Debug,Serialize)]
563           struct Data<'r> {
564             unix_user: &'r str,
565             #[serde(flatten)]
566             common: CommonData<'r>,
567           }
568           let data = Data {
569             unix_user: user,
570             common,
571           };
572           command.args(&["-f", user]);
573           nwtemplates::render("token-unix.tera", &data)
574         }
575         _ => {
576           #[derive(Debug,Serialize)]
577           struct Data<'r> {
578             account: String,
579             #[serde(flatten)]
580             common: CommonData<'r>,
581           }
582           let data = Data {
583             account: account.to_string(),
584             common,
585           };
586           nwtemplates::render("token-other.tera", &data)
587         },
588       }
589       .context("render email template")?;
590
591       let messagefile = (||{
592         let mut messagefile = tempfile::tempfile().context("tempfile")?;
593         messagefile.write_all(message.as_bytes()).context("write")?;
594         messagefile.flush().context("flush")?;
595         messagefile.rewind().context("seek")?;
596         Ok::<_,AE>(messagefile)
597       })().context("write email to temporary file.")?;
598
599       command
600         .args(&["-oee","-odb","-oi","-t","--"])
601         .stdin(messagefile);
602       unsafe {
603         command.pre_exec(|| {
604           // https://github.com/rust-lang/rust/issues/79731
605           match libc::dup2(2,1) {
606             1 => Ok(()),
607             -1 => Err(io::Error::last_os_error()),
608             x => panic!("dup2(2,1) gave {}", x),
609           }
610         });
611       }
612       let st = command
613         .status()
614         .with_context(|| format!("run sendmail ({})", sendmail))?;
615       if !st.success() {
616         throw!(anyhow!("sendmail ({}) failed: {} ({})", sendmail, st, st));
617       }
618
619       AccessTokenReport { lines: vec![
620         "Token sent by email.".to_string()
621       ]}
622     }
623   }
624
625   #[ext(pub, name=ColourSpecExt)]
626   impl Option<ColourSpec> {
627     #[throws(UnsupportedColourSpec)]
628     fn resolve(&self) -> Colour {
629       self.as_ref()
630         .map(TryInto::try_into).transpose()?
631         .unwrap_or_else(|| Html::lit("black").into())
632     }
633   }
634
635   impl TryFrom<&ColourSpec> for Colour {
636     type Error = UnsupportedColourSpec;
637     #[throws(UnsupportedColourSpec)]
638     fn try_from(spec: &ColourSpec) -> Colour {
639       lazy_static! {
640         static ref RE: Regex = Regex::new(concat!(
641           r"^(?:", r"[[:alpha:]]{1,50}",
642           r"|", r"#[[:xdigit:]]{3}{1,2}",
643           r"|", r"(?:rgba?|hsla?)\([-.%\t 0-9]{1,50}\)",
644           r")$"
645         )).unwrap();
646       }
647       let s = &spec.0;
648       if !RE.is_match(s) {
649         throw!(UnsupportedColourSpec);
650       }
651       Html::from_html_string(spec.0.clone())
652     }
653   }
654
655   impl TextOptionsSpec {
656     #[throws(SpecError)]
657     /// Default colour is always black
658     pub fn resolve(&self, default_size: f64) -> TextOptions {
659       let TextOptionsSpec { colour, size } = self;
660       let colour = colour.resolve()?;
661       let size = size.unwrap_or(default_size);
662       TextOptions { colour, size }
663     }
664   }
665
666   #[ext(pub)]
667   impl Option<PieceAngleSpec> {
668     #[throws(SpecError)]
669     fn resolve(&self) -> PieceAngle {
670       use PieceAngleSpec as PAS;
671       let i = match self {
672         None => return default(),
673         Some(PAS::Compass(s)) => {
674           let (i,_) = [
675             "N" , "NE", "E" , "SE", "S" , "SW", "W" , "NW",
676           ].iter().enumerate().find(|(_i,&exp)| s == exp)
677             .ok_or_else(|| SpE::CompassAngleInvalid)?;
678           i as u8
679         },
680         Some(PAS::Degrees(deg)) => {
681           let deg = deg.rem_euclid(360);
682           if deg % 45 != 0 { throw!(SpE::CompassAngleInvalid) }
683           (deg / 45) as u8
684         },
685         Some(PAS::CompatMF1 { i }) => *i,
686       };
687       PieceAngle::Compass(i.try_into()?)
688     }
689   }
690
691   impl UrlSpec {
692     const MAX_LEN: usize = 200;
693   }
694
695   impl TryFrom<&UrlSpec> for Url {
696     type Error = SpecError;
697     #[throws(SpecError)]
698     fn try_from(spec: &UrlSpec) -> Url {
699       if spec.0.len() > UrlSpec::MAX_LEN {
700         throw!(SpE::UrlTooLong);
701       }
702       let base = Url::parse(&config().public_url)
703         .or_else(|_| Url::parse(
704           "https://bad-otter-config-public-url.example.net/"
705         )).unwrap();
706       let url = Url::options()
707         .base_url(Some(&base))
708         .parse(&spec.0)
709         .map_err(|_| SpE::BadUrlSyntax)?;
710       url
711     }
712   }
713 }