chiark / gitweb /
9fea2654796e1da8c73e8185ae00e90e18b0beaf
[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
240 #[derive(Debug,Copy,Clone,Serialize,Deserialize)]
241 pub enum PieceAngle {
242   Compass(CompassAngle),
243 }
244 impl PieceAngle {
245   pub fn is_default(&self) -> bool { match self {
246     PieceAngle::Compass(a) => *a == CompassAngle::default(),
247     #[allow(unreachable_patterns)] _ => false,
248   } }
249 }
250
251 #[derive(Debug,Default,Clone,Serialize,Deserialize)]
252 pub struct TextOptionsSpec {
253   pub colour: Option<ColourSpec>,
254   pub size: Option<f64>,
255 }
256
257 #[derive(Debug,Copy,Clone,Eq,PartialEq)]
258 #[derive(Default,Serialize,Deserialize)]
259 #[serde(try_from="u8")]
260 #[serde(into="u8")]
261 /// 0 = unrotated, +ve is anticlockwise, units of 45deg
262 pub struct CompassAngle(u8);
263
264 //---------- Piece specs ----------
265 // the implementations are in shapelib.rs and pieces.rs
266
267 #[derive(Debug,Clone,Serialize,Deserialize)]
268 pub struct ItemSpec {
269   pub lib: String,
270   pub item: String,
271 }
272
273 pub mod piece_specs {
274   use super::*;
275
276   pub type FaceColourSpecs = IndexVec<FaceId,ColourSpec>;
277
278   #[derive(Debug,Serialize,Deserialize)]
279   pub struct SimpleCommon {
280     pub itemname: Option<String>,
281     pub faces: IndexVec<FaceId, ColourSpec>,
282     #[serde(default)] pub edges: IndexVec<FaceId, ColourSpec>,
283     pub edge_width: Option<f64>,
284   }
285
286   #[derive(Debug,Serialize,Deserialize)]
287   pub struct Disc {
288     pub diam: Coord,
289     #[serde(flatten)]
290     pub common: SimpleCommon,
291   }
292
293   #[derive(Debug,Serialize,Deserialize)]
294   pub struct Rect {
295     pub size: Vec<Coord>,
296     #[serde(flatten)]
297     pub common: SimpleCommon,
298   }
299
300   #[derive(Debug,Serialize,Deserialize)]
301   pub struct Hand {
302     #[serde(flatten)] pub c: OwnedCommon,
303   }
304
305   #[derive(Debug,Serialize,Deserialize)]
306   pub struct PlayerLabel {
307     #[serde(flatten)] pub c: OwnedCommon,
308   }
309
310   #[derive(Debug,Serialize,Deserialize)]
311   pub struct OwnedCommon {
312     pub colour: ColourSpec,
313     pub edge: Option<ColourSpec>,
314     pub edge_width: Option<f64>,
315     pub shape: Outline,
316     pub label: Option<PieceLabel>,
317   }
318
319   #[derive(Debug,Clone,Serialize,Deserialize)]
320   pub struct PieceLabel {
321     #[serde(default)] pub place: PieceLabelPlace,
322     pub colour: Option<ColourSpec>,
323   }
324
325   #[derive(Debug,Copy,Clone,Serialize,Deserialize,Eq,PartialEq)]
326   pub enum PieceLabelPlace {
327     BottomLeft,        TopLeft,
328     BottomLeftOutside, TopLeftOutside,
329   }
330
331   #[derive(Debug,Serialize,Deserialize)]
332   pub struct Deck {
333     pub faces: IndexVec<FaceId, ColourSpec>,
334     #[serde(default)] pub edges: IndexVec<FaceId, ColourSpec>,
335     pub edge_width: Option<f64>,
336     pub shape: Outline,
337     pub label: Option<PieceLabel>,
338     #[serde(default)] pub stack_pos: [Coord; 2],
339   }
340 }
341
342 // ---------- Implementation - angles ----------
343
344 impl PieceAngle {
345   pub fn is_rotated(&self) -> bool { match self {
346     &PieceAngle::Compass(CompassAngle(a)) => a != 0,
347   } }
348 }
349
350 impl Default for PieceAngle {
351   fn default() -> Self { PieceAngle::Compass(default()) }
352 }
353
354 impl TryFrom<u8> for CompassAngle {
355   type Error = SpecError;
356   #[throws(SpecError)]
357   fn try_from(v: u8) -> Self {
358     if v < 8 { Self(v) }
359     else { throw!(SpE::CompassAngleInvalid) }
360   }
361 }
362
363 impl From<CompassAngle> for u8 {
364   fn from(a: CompassAngle) -> u8 {
365     a.0
366   }
367 }
368
369 //---------- Implementation ----------
370
371 pub mod imp {
372   use super::{*, SpE};
373   use crate::prelude::*;
374
375   type AS = AccountScope;
376   type TPS = TablePlayerSpec;
377
378   pub fn def_table_size() -> Pos {
379     DEFAULT_TABLE_SIZE
380   }
381   pub fn def_table_colour() -> ColourSpec {
382     ColourSpec(DEFAULT_TABLE_COLOUR.into())
383   }
384
385   impl Default for piece_specs::PieceLabelPlace {
386     fn default() -> Self { Self::BottomLeft }
387   }
388
389   impl<P: Eq + Hash> Default for Acl<P> {
390     fn default() -> Self { Acl { ents: default() } }
391   }
392
393   impl<P: Eq + Hash + Serialize> Serialize for Acl<P> {
394     fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error>
395     { self.ents.serialize(s) }
396   }
397
398   impl<P: Eq + Hash> TryFrom<RawAcl<P>> for Acl<P> {
399     type Error = SpecError;
400     #[throws(SpecError)]
401     fn try_from(ents: RawAcl<P>) -> Self {
402       for ent in &ents {
403         glob::Pattern::new(&ent.account_glob)
404           .map_err(|_| SpE::AclInvalidAccountGlob)?;
405         if ! ent.deny.is_disjoint(&ent.allow) {
406           throw!(SpE::AclEntryOverlappingAllowDeny);
407         }
408       }
409       Acl { ents }
410     }
411   }
412
413   impl loaded_acl::Perm for TablePermission {
414     type Auth = InstanceName;
415     const TEST_EXISTENCE: Self = TablePermission::TestExistence;
416     const NOT_FOUND: MgmtError = ME::GameNotFound;
417   }
418
419   impl TablePlayerSpec {
420     pub fn account_glob(&self, instance_name: &InstanceName) -> String {
421       fn scope_glob(scope: AccountScope) -> String {
422         let mut out = "".to_string();
423         scope.display_name(&[""], |s| Ok::<_,Void>(out += s)).unwrap();
424         out += "*";
425         out
426       }
427       match self {
428         TPS::Account(account) => account.to_string(),
429         TPS::AccountGlob(s) => s.clone(),
430         TPS::SameScope => scope_glob(instance_name.account.scope.clone()),
431         TPS::Local(user) => scope_glob(AS::Unix { user: user.clone() }),
432         TPS::AllLocal => {
433           // abuse that usernames are not encoded
434           scope_glob(AS::Unix { user: "*".into() })
435         }
436       }
437     }
438   }
439
440   type TDE = TokenDeliveryError;
441
442   pub fn raw_token_debug_as_str(s: &str, f: &mut fmt::Formatter)
443                                 -> fmt::Result {
444     let len = min(5, s.len() / 2);
445     write!(f, "{:?}...", &s[0..len])
446   }
447
448   impl Debug for RawToken {
449     fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
450       raw_token_debug_as_str(&self.0, f)
451     }
452   }
453
454   #[typetag::serde(tag="access")]
455   pub trait PlayerAccessSpec: Debug + Sync + Send {
456     fn override_token(&self) -> Option<&RawToken> {
457       None
458     }
459     #[throws(MgmtError)]
460     fn check_spec_permission(&self, _: Option<AuthorisationSuperuser>) {
461     }
462     fn deliver(&self,
463                ag: &AccountsGuard,
464                g: &Instance,
465                gpl: &GPlayer,
466                ipl: &IPlayer,
467                token: AccessTokenInfo)
468                -> Result<AccessTokenReport, TDE>;
469     fn describe_html(&self) -> Html {
470       let inner = Html::from_txt(&format!("{:?}", self));
471       hformat!("<code>{}</code>", inner)
472     }
473   }
474
475   #[typetag::serde]
476   impl PlayerAccessSpec for PlayerAccessUnset {
477     #[throws(TokenDeliveryError)]
478     fn deliver(&self,
479                _ag: &AccountsGuard,
480                _g: &Instance,
481                _gpl: &GPlayer,
482                _ipl: &IPlayer,
483                _token: AccessTokenInfo) -> AccessTokenReport {
484       AccessTokenReport { lines: vec![
485         "Player access not set, game not accessible to this player"
486           .to_string(),
487       ] }
488     }
489   }
490
491   #[typetag::serde]
492   impl PlayerAccessSpec for FixedToken {
493     #[throws(MgmtError)]
494     fn check_spec_permission(&self, auth: Option<AuthorisationSuperuser>) {
495       auth.ok_or(ME::SuperuserAuthorisationRequired)?
496     }
497     fn override_token(&self) -> Option<&RawToken> {
498       Some(&self.token)
499     }
500     #[throws(TokenDeliveryError)]
501     fn deliver(&self,
502                _ag: &AccountsGuard,
503                _g: &Instance,
504                _gpl: &GPlayer,
505                _ipl: &IPlayer,
506                _token: AccessTokenInfo) -> AccessTokenReport {
507       AccessTokenReport { lines: vec![ "Fixed access token".to_string() ] }
508     }
509   }
510
511   #[typetag::serde]
512   impl PlayerAccessSpec for UrlOnStdout {
513     #[throws(TDE)]
514     fn deliver<'t>(&self,
515                    _ag: &AccountsGuard,
516                    _g: &Instance,
517                    _gpl: &GPlayer,
518                    _ipl: &IPlayer,
519                    token: AccessTokenInfo)
520                    -> AccessTokenReport {
521       AccessTokenReport { lines: token.report() }
522     }
523   }
524
525   #[typetag::serde]
526   impl PlayerAccessSpec for TokenByEmail {
527     #[throws(TDE)]
528     fn deliver<'t>(&self,
529                    ag: &AccountsGuard,
530                    g: &Instance,
531                    gpl: &GPlayer,
532                    ipl: &IPlayer,
533                    token: AccessTokenInfo)
534                    -> AccessTokenReport {
535       let sendmail = &config().sendmail;
536       let mut command = Command::new(sendmail);
537
538       #[derive(Debug,Serialize)]
539       struct CommonData<'r> {
540         player_email: &'r str,
541         game_name: String,
542         nick: &'r str,
543         token_lines: Vec<String>,
544       }
545       let common = CommonData {
546         player_email: &self.addr,
547         game_name: g.name.to_string(),
548         token_lines: token.report(),
549         nick: &gpl.nick,
550       };
551
552       if self.addr.find((&['\r','\n']) as &[char]).is_some() {
553         throw!(anyhow!("email address may not contain line endings"));
554       }
555
556       let (account, _) = ag.lookup(ipl.acctid).context("find account")?;
557       let account = &account.account;
558       let message = match &account.scope {
559         AS::Unix { user } => {
560           #[derive(Debug,Serialize)]
561           struct Data<'r> {
562             unix_user: &'r str,
563             #[serde(flatten)]
564             common: CommonData<'r>,
565           }
566           let data = Data {
567             unix_user: user,
568             common,
569           };
570           command.args(&["-f", user]);
571           nwtemplates::render("token-unix.tera", &data)
572         }
573         _ => {
574           #[derive(Debug,Serialize)]
575           struct Data<'r> {
576             account: String,
577             #[serde(flatten)]
578             common: CommonData<'r>,
579           }
580           let data = Data {
581             account: account.to_string(),
582             common,
583           };
584           nwtemplates::render("token-other.tera", &data)
585         },
586       }
587       .context("render email template")?;
588
589       let messagefile = (||{
590         let mut messagefile = tempfile::tempfile().context("tempfile")?;
591         messagefile.write_all(message.as_bytes()).context("write")?;
592         messagefile.flush().context("flush")?;
593         messagefile.rewind().context("seek")?;
594         Ok::<_,AE>(messagefile)
595       })().context("write email to temporary file.")?;
596
597       command
598         .args(&["-oee","-odb","-oi","-t","--"])
599         .stdin(messagefile);
600       unsafe {
601         command.pre_exec(|| {
602           // https://github.com/rust-lang/rust/issues/79731
603           match libc::dup2(2,1) {
604             1 => Ok(()),
605             -1 => Err(io::Error::last_os_error()),
606             x => panic!("dup2(2,1) gave {}", x),
607           }
608         });
609       }
610       let st = command
611         .status()
612         .with_context(|| format!("run sendmail ({})", sendmail))?;
613       if !st.success() {
614         throw!(anyhow!("sendmail ({}) failed: {} ({})", sendmail, st, st));
615       }
616
617       AccessTokenReport { lines: vec![
618         "Token sent by email.".to_string()
619       ]}
620     }
621   }
622
623   #[ext(pub, name=ColourSpecExt)]
624   impl Option<ColourSpec> {
625     #[throws(UnsupportedColourSpec)]
626     fn resolve(&self) -> Colour {
627       self.as_ref()
628         .map(TryInto::try_into).transpose()?
629         .unwrap_or_else(|| Html::lit("black").into())
630     }
631   }
632
633   impl TryFrom<&ColourSpec> for Colour {
634     type Error = UnsupportedColourSpec;
635     #[throws(UnsupportedColourSpec)]
636     fn try_from(spec: &ColourSpec) -> Colour {
637       lazy_static! {
638         static ref RE: Regex = Regex::new(concat!(
639           r"^(?:", r"[[:alpha:]]{1,50}",
640           r"|", r"#[[:xdigit:]]{3}{1,2}",
641           r"|", r"(?:rgba?|hsla?)\([-.%\t 0-9]{1,50}\)",
642           r")$"
643         )).unwrap();
644       }
645       let s = &spec.0;
646       if !RE.is_match(s) {
647         throw!(UnsupportedColourSpec);
648       }
649       Html::from_html_string(spec.0.clone())
650     }
651   }
652
653   impl TextOptionsSpec {
654     #[throws(SpecError)]
655     /// Default colour is always black
656     pub fn resolve(&self, default_size: f64) -> TextOptions {
657       let TextOptionsSpec { colour, size } = self;
658       let colour = colour.resolve()?;
659       let size = size.unwrap_or(default_size);
660       TextOptions { colour, size }
661     }
662   }
663
664   #[ext(pub)]
665   impl Option<PieceAngleSpec> {
666     #[throws(SpecError)]
667     fn resolve(&self) -> PieceAngle {
668       use PieceAngleSpec as PAS;
669       let i = match self {
670         None => return default(),
671         Some(PAS::Compass(s)) => {
672           let (i,_) = [
673             "N" , "NE", "E" , "SE", "S" , "SW", "W" , "NW",
674           ].iter().enumerate().find(|(_i,&exp)| s == exp)
675             .ok_or_else(|| SpE::CompassAngleInvalid)?;
676           i as u8
677         },
678         Some(PAS::Degrees(deg)) => {
679           let deg = deg.rem_euclid(360);
680           if deg % 45 != 0 { throw!(SpE::CompassAngleInvalid) }
681           (deg / 45) as u8
682         },
683       };
684       PieceAngle::Compass(i.try_into()?)
685     }
686   }
687
688   impl UrlSpec {
689     const MAX_LEN: usize = 200;
690   }
691
692   impl TryFrom<&UrlSpec> for Url {
693     type Error = SpecError;
694     #[throws(SpecError)]
695     fn try_from(spec: &UrlSpec) -> Url {
696       if spec.0.len() > UrlSpec::MAX_LEN {
697         throw!(SpE::UrlTooLong);
698       }
699       let base = Url::parse(&config().public_url)
700         .or_else(|_| Url::parse(
701           "https://bad-otter-config-public-url.example.net/"
702         )).unwrap();
703       let url = Url::options()
704         .base_url(Some(&base))
705         .parse(&spec.0)
706         .map_err(|_| SpE::BadUrlSyntax)?;
707       url
708     }
709   }
710 }