Something went wrong. Try again.
Web frontend and supporting services for lance.blue
Something went wrong. Try again.
177 kB · 4078 lines
Rust
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717271827192720272127222723272427252726272727282729273027312732273327342735273627372738273927402741274227432744274527462747274827492750275127522753275427552756275727582759276027612762276327642765276627672768276927702771277227732774277527762777277827792780278127822783278427852786278727882789279027912792279327942795279627972798279928002801280228032804280528062807280828092810281128122813281428152816281728182819282028212822282328242825282628272828282928302831283228332834283528362837283828392840284128422843284428452846284728482849285028512852285328542855285628572858285928602861286228632864286528662867286828692870287128722873287428752876287728782879288028812882288328842885288628872888288928902891289228932894289528962897289828992900290129022903290429052906290729082909291029112912291329142915291629172918291929202921292229232924292529262927292829292930293129322933293429352936293729382939294029412942294329442945294629472948294929502951295229532954295529562957295829592960296129622963296429652966296729682969297029712972297329742975297629772978297929802981298229832984298529862987298829892990299129922993299429952996299729982999300030013002300330043005300630073008300930103011301230133014301530163017301830193020302130223023302430253026302730283029303030313032303330343035303630373038303930403041304230433044304530463047304830493050305130523053305430553056305730583059306030613062306330643065306630673068306930703071307230733074307530763077307830793080308130823083308430853086308730883089309030913092309330943095309630973098309931003101310231033104310531063107310831093110311131123113311431153116311731183119312031213122312331243125312631273128312931303131313231333134313531363137313831393140314131423143314431453146314731483149315031513152315331543155315631573158315931603161316231633164316531663167316831693170317131723173317431753176317731783179318031813182318331843185318631873188318931903191319231933194319531963197319831993200320132023203320432053206320732083209321032113212321332143215321632173218321932203221322232233224322532263227322832293230323132323233323432353236323732383239324032413242324332443245324632473248324932503251325232533254325532563257325832593260326132623263326432653266326732683269327032713272327332743275327632773278327932803281328232833284328532863287328832893290329132923293329432953296329732983299330033013302330333043305330633073308330933103311331233133314331533163317331833193320332133223323332433253326332733283329333033313332333333343335333633373338333933403341334233433344334533463347334833493350335133523353335433553356335733583359336033613362336333643365336633673368336933703371337233733374337533763377337833793380338133823383338433853386338733883389339033913392339333943395339633973398339934003401340234033404340534063407340834093410341134123413341434153416341734183419342034213422342334243425342634273428342934303431343234333434343534363437343834393440344134423443344434453446344734483449345034513452345334543455345634573458345934603461346234633464346534663467346834693470347134723473347434753476347734783479348034813482348334843485348634873488348934903491349234933494349534963497349834993500350135023503350435053506350735083509351035113512351335143515351635173518351935203521352235233524352535263527352835293530353135323533353435353536353735383539354035413542354335443545354635473548354935503551355235533554355535563557355835593560356135623563356435653566356735683569357035713572357335743575357635773578357935803581358235833584358535863587358835893590359135923593359435953596359735983599360036013602360336043605360636073608360936103611361236133614361536163617361836193620362136223623362436253626362736283629363036313632363336343635363636373638363936403641364236433644364536463647364836493650365136523653365436553656365736583659366036613662366336643665366636673668366936703671367236733674367536763677367836793680368136823683368436853686368736883689369036913692369336943695369636973698369937003701370237033704370537063707370837093710371137123713371437153716371737183719372037213722372337243725372637273728372937303731373237333734373537363737373837393740374137423743374437453746374737483749375037513752375337543755375637573758375937603761376237633764376537663767376837693770377137723773377437753776377737783779378037813782378337843785378637873788378937903791379237933794379537963797379837993800380138023803380438053806380738083809381038113812381338143815381638173818381938203821382238233824382538263827382838293830383138323833383438353836383738383839384038413842384338443845384638473848384938503851385238533854385538563857385838593860386138623863386438653866386738683869387038713872387338743875387638773878387938803881388238833884388538863887388838893890389138923893389438953896389738983899390039013902390339043905390639073908390939103911391239133914391539163917391839193920392139223923392439253926392739283929393039313932393339343935393639373938393939403941394239433944394539463947394839493950395139523953395439553956395739583959396039613962396339643965396639673968396939703971397239733974397539763977397839793980398139823983398439853986398739883989399039913992399339943995399639973998399940004001400240034004400540064007400840094010401140124013401440154016401740184019402040214022402340244025402640274028402940304031403240334034403540364037403840394040404140424043404440454046404740484049405040514052405340544055405640574058405940604061406240634064406540664067406840694070407140724073407440754076407740784079//! The public face of a finished match: one page per result, and the picture//! a link to it unfurls with.//!//! Everything else this API serves is behind a session, and this is the one//! thing that cannot be. A player who posts their match to Bluesky is posting//! a URL that Bluesky's card service fetches with no cookie of theirs, and a//! preview nobody can fetch is a bare link. So these two routes are open, and//! what they say is bounded by that: the result document a match produced -//! handles, sides, what was left standing - and nothing about the account//! behind a handle, the session that launched the match, or any artifact//! except the one board render arena publishes for this purpose.//!//! Reachable only with the match id, which is a version 4 UUID: not secret,//! but not enumerable either, and it is already in the player's own address//! bar. A match with no result answers 404 - there is nothing to show and//! nothing to unfurl - so a page exists exactly when a fight finished.//!//! The words are built here rather than on the site because the site is a//! static build: a per-match og:image needs a per-match document, and there//! is no per-match file to prerender.
use axum::extract::{Path, State};use axum::http::{HeaderMap, HeaderValue, StatusCode, header};use axum::response::{Html, IntoResponse, Response};
use crate::card;use crate::routes::AppState;
/// How a match was entered, which is what the top of its page says.////// A daily challenge, a duel and a scenario are three different things to/// have played, and a page that presents them identically throws away the/// one piece of context a reader arriving from a link has none of. The site/// calls them by these names; so does this.enum Entry { /// The day's challenge, and the day it ran. The challenge's own name /// comes from the label like every other scenario's does. Daily { day: String }, /// Two or more people. What the site's Duel card opens. Duel, /// Anything else: a scenario played against Princess. Scenario,}
impl Entry { fn eyebrow(&self) -> &'static str { match self { Entry::Daily { .. } => "Daily challenge", Entry::Duel => "Duel", Entry::Scenario => "Scenario", } }}
/// What a scenario says about itself, for the cards that draw it.////// Read through the `mms` crate rather than restated here: the front page/// places a challenge's forces by the same `deploy` field and prints the same/// `description`, and two readings of one file drift.#[derive(Default)]pub struct Brief { /// The scenario's own description, which the front page prints under the /// challenge's name. blurb: String, /// Faction name to the zone it starts in. zones: Vec<(String, String)>,}
impl Brief { /// From a scenario's text. Empty for a match whose scenario this service /// does not carry - a catalog fight lives in the arena image. fn read(text: &str) -> Brief { let blurb = headquarters_mms::parse(text) .ok() .and_then(|scenario| scenario.description().map(str::to_owned)) .unwrap_or_default(); let zones = headquarters_mms::factions(text) .into_iter() .filter_map(|faction| faction.deploy.map(|zone| (faction.name, zone))) .collect(); Brief { blurb, zones } }
fn zone_of(&self, slot: &str) -> Option<String> { self.zones .iter() .find(|(name, _)| name == slot) .map(|(_, zone)| zone.clone()) }}
/// What the page knows about the match beyond its result: what was fought,/// how it was reached, and when.////// None of it is in the result document - arena records what happened, not/// what it was called - so it is read off the match row and its seats.pub struct Context { entry: Entry, /// The daily's slug, for looking its scenario back up. slug: Option<String>, /// The scenario's name, as a person would say it. scenario: Option<String>, /// The day the match was played, as YYYY-MM-DD. played: Option<String>, /// How long it took, in seconds, from playable to decided. took: Option<i64>,}
impl Context { /// Read from the row, its seats and the daily catalog. /// /// A duel is two or more people rather than a flag on the row: the site's /// Duel card and its Scenario card launch the same kind of match, and /// what makes it a duel is that somebody else was in it. pub fn read(row: &crate::db::MatchRow, seats: &[crate::db::MatchPlayer]) -> Context { let label = row.scenario.clone().unwrap_or_default(); let entry = if let Some(slug) = label.strip_prefix("daily:") { Entry::Daily { day: crate::matches::daily::challenge(slug) .map(|c| c.date.to_owned()) .unwrap_or_default(), } } else if seats.iter().filter(|seat| seat.control == "human").count() > 1 { Entry::Duel } else { Entry::Scenario }; Context { slug: label.strip_prefix("daily:").map(str::to_owned), scenario: scenario_name(&label), played: day_of(row.created_at), // Measured by the API rather than by the match: the row is // stamped when the container says it is playable and again when // it says the game is decided. That spans the lobby, so it is // how long the match took rather than how long it was fought. took: match (row.started_at, row.ended_at) { (Some(from), Some(to)) if to > from => Some(to - from), _ => None, }, entry, } }
/// The line under the title: what was played, and when. fn line(&self) -> String { let mut parts: Vec<String> = Vec::new(); if let Some(name) = &self.scenario { parts.push(escape(name)); } if let Entry::Daily { day } = &self.entry && !day.is_empty() { parts.push(escape(day)); } else if let Some(played) = &self.played { parts.push(escape(played)); } parts.join(" · ") }
/// What the scenario says about itself, when this service has the file. /// Only a daily's is compiled in; a catalog scenario lives in the arena /// image and this is empty for it. pub fn brief(&self) -> Brief { match &self.entry { Entry::Daily { .. } => self .slug .as_deref() .and_then(crate::matches::daily::challenge) .map(|challenge| Brief::read(challenge.scenario)) .unwrap_or_default(), _ => Brief::default(), } }
/// How the match is designated on a card: "SCENARIO: FIRST RUN". The kind /// on its own is a label; with the name it is what was fought. /// /// Except on the daily card, whose headline *is* the challenge's name - /// there the label stays a label rather than saying it twice. fn designation(&self, layout: card::Layout) -> String { match &self.scenario { Some(name) if layout != card::Layout::Daily => { format!("{}: {}", self.entry.eyebrow(), name) } _ => self.entry.eyebrow().to_owned(), } }
/// The faint line at the foot of a card: when it was fought, how long it /// ran, and how long it took. /// /// Not the scenario's name - the designation across the top already says /// that, and a card should not introduce itself twice. Match length is /// absent for now: arena records when a match was decided and not how /// long it took, so this says rounds until the result carries a clock. fn footer_line(&self, round: i64) -> String { let mut parts: Vec<String> = Vec::new(); if let Entry::Daily { day } = &self.entry && !day.is_empty() { parts.push(day.clone()); } else if let Some(played) = &self.played { parts.push(played.clone()); } parts.push(format!( "{round} round{}", if round == 1 { "" } else { "s" } )); if let Some(took) = self.took { parts.push(elapsed(took)); } parts.join(" · ") }}
/// A duration, as long as it needs to be and no longer: "8m", "1h 12m", and/// seconds only for a match that ended before a minute was up.fn elapsed(seconds: i64) -> String { let minutes = seconds / 60; if minutes == 0 { return format!("{seconds}s"); } let (hours, minutes) = (minutes / 60, minutes % 60); if hours == 0 { format!("{minutes}m") } else { format!("{hours}h {minutes}m") }}
/// A scenario's name from the label the row carries.////// A daily's slug is a file name and reads as one ("bug-company"), so it is/// turned back into words. A catalog match carries the path into the arena/// image's library, whose last element is the scenario's own file name.fn scenario_name(label: &str) -> Option<String> { let raw = match label.strip_prefix("daily:") { Some(slug) => slug.to_owned(), None => { if label.is_empty() { return None; } label .rsplit('/') .next() .unwrap_or(label) .trim_end_matches(".mms") .to_owned() } }; if raw.is_empty() { return None; } // A file name is not a title. Three conventions the libraries actually // use, in order: the training scenarios number themselves ("1-FirstRun"), // the dailies are slugs ("bug-company"), and both run words together in // camel case where a person would put a space. let unnumbered = match raw.split_once(['-', '_', ' ']) { Some((head, tail)) if !head.is_empty() && head.chars().all(|c| c.is_ascii_digit()) => tail, _ => &raw, }; let mut spaced = String::with_capacity(unnumbered.len() + 4); let mut previous = ' '; for c in unnumbered.chars() { // Only after a lower-case letter, so "PPC" and "LRM" survive whole. if c.is_uppercase() && previous.is_lowercase() { spaced.push(' '); } spaced.push(if c == '-' || c == '_' { ' ' } else { c }); previous = c; } let mut out = String::with_capacity(spaced.len()); for word in spaced.split_whitespace() { if !out.is_empty() { out.push(' '); } let mut chars = word.chars(); match chars.next() { // Left alone when it already carries capitals: a catalog file is // named "Monte Diablo", not "monte diablo". Some(first) if word.chars().any(char::is_uppercase) => { out.push(first); out.push_str(chars.as_str()); } Some(first) => { out.extend(first.to_uppercase()); out.push_str(chars.as_str()); } None => {} } } Some(out)}
/// The day a Unix timestamp falls on, UTC, as YYYY-MM-DD.////// Days rather than minutes: a match page is read long after the match, and/// the hour it was played at is nobody's business but the players'.fn day_of(seconds: i64) -> Option<String> { if seconds <= 0 { return None; } // Howard Hinnant's civil-from-days, the same algorithm the launch // manifest dates itself with. let days = seconds.div_euclid(86_400) + 719_468; let era = days.div_euclid(146_097); let doe = days.rem_euclid(146_097); let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; let y = yoe + era * 400; let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let d = doy - (153 * mp + 2) / 5 + 1; let m = if mp < 10 { mp + 3 } else { mp - 9 }; let year = if m <= 2 { y + 1 } else { y }; Some(format!("{year:04}-{m:02}-{d:02}"))}
/// Whose match this is, when somebody asked for their own view of it.////// "@a beat @b" is the honest phrasing of a result and an awkward thing to/// post about yourself, and it falls apart entirely in a 2v2, where two/// people won and two lost and each of the four has a different sentence to/// write. So a card can be asked for from one player's point of view: they/// are "you", their side is the one drawn in detail, and the verdict is/// theirs. Named by handle in the URL because that is what a player would/// type and paste; matched against the handles the result recorded.#[derive(Clone)]pub struct Perspective { handle: String,}
impl Perspective { /// From `?perspective=`, with or without its leading `@`. fn read(raw: Option<&str>) -> Option<Perspective> { let handle = raw?.trim().trim_start_matches('@').to_lowercase(); if handle.is_empty() { return None; } Some(Perspective { handle }) }
/// Whether a force is theirs. Forces are named "@handle" in the result. fn owns(&self, force: &Force) -> bool { force.name.trim_start_matches('@').to_lowercase() == self.handle }}
/// The one word a card shouts, when it was asked for from a side.////// VICTORY and DEFEAT are what a player is posting; "@a won" is a caption on/// it. Empty from nobody's point of view, where there is no single verdict to/// shout - a 2v2 seen from outside had two of each.fn verdict_word(summary: &Summary, perspective: Option<&Perspective>) -> &'static str { let Some(perspective) = perspective else { return ""; }; let Some(mine) = summary.forces.iter().find(|f| perspective.owns(f)) else { return ""; }; if !summary.decided() { return "DRAW"; } if summary.won(mine) { "VICTORY" } else { "DEFEAT" }}
/// The verdict, phrased for whoever asked.////// From nobody's point of view it is a report: "@a beat @b". From a player's/// it is theirs to post: "you won", said with their own handle so the card/// still names somebody when it is read by a stranger.fn verdict_line(summary: &Summary, perspective: Option<&Perspective>) -> String { let Some(perspective) = perspective else { return summary.title(); }; let Some(mine) = summary.forces.iter().find(|f| perspective.owns(f)) else { return summary.title(); }; if !summary.decided() { return format!("{} drew", mine.name); } if summary.won(mine) { format!("{} won", mine.name) } else { format!("{} lost", mine.name) }}
/// The line beside the way in, for a reader who arrived from a shared post/// and has never heard of any of this.pub(crate) const TAGLINE: &str = "Tactical combat on the Atmosphere.";
/// The alpha feedback survey, linked from the foot of a match report.////// This page is where a player lands when a match ends: the screen inside the/// container hands off to it within a few seconds of the result existing, and/// unlike that screen it stays put. It is also public, so a stranger who/// followed a shared link sees the link too - which is the same bargain the/// "Play lance.blue" button beside it already makes.////// A constant, the way the flare board's address is, and `web/src/survey.ts`/// and `arena/suramadu/web/survey.js` carry their own copies of it: three/// bundles that never share code, one form.const SURVEY_URL: &str = "https://docs.google.com/forms/d/e/1FAIpQLSdT7hshO7t3JhbvdTB74ixx8D0923ViqDwnhsUXcrYHSwTMFQ/viewform";
/// What the link is called. A control's name, not copy.const SURVEY_LABEL: &str = "Alpha survey";
/// Which of MegaMek's own default silhouettes a machine falls back to.////// A path under the release's `data/images/units`, so it is fetched and/// addressed exactly like a design's own art - there is one library and one/// pipeline, and nothing about a machine's picture is committed here.////// The names are MegaMek's, from the `default_*` keys in its `mekset.txt`,/// and the Mek bands are the game's own weight classes. This is what a design/// with no art of its own gets in MegaMek, which makes it the right answer/// for every machine helm's index does not carry - every vehicle, platoon and/// fighter, since the index holds Meks alone.////// `None` for a kind MegaMek has no default for, and the machine is drawn/// without a picture rather than as a guess at what it might look like.fn sprite_for(unit: &serde_json::Value) -> Option<&'static str> { let kind = unit .get("kind") .and_then(serde_json::Value::as_str) .unwrap_or("unit"); let tons = unit .get("tons") .and_then(serde_json::Value::as_f64) .unwrap_or(0.0); Some(match kind { // The weight classes, which MegaMek draws a silhouette per. The old // three-way split sent every medium Mek to a Timber Wolf and every // assault to an Atlas - a specific machine standing in for a class. "mek" if tons > 100.0 => "defaults/default_super_heavy_mek.png", "mek" if tons >= 80.0 => "defaults/default_assault.png", "mek" if tons >= 60.0 => "defaults/default_heavy.png", "mek" if tons >= 40.0 => "defaults/default_medium.png", "mek" if tons >= 20.0 => "defaults/default_light.png", "mek" => "defaults/default_ultra_light.png", "battle armor" => "defaults/default_ba.png", "infantry" => "defaults/default_infantry_platoon.png", "protomek" => "defaults/default_proto.png", "aerospace" => "defaults/asf.png", "spacecraft" => "defaults/dropship_spheroid.png", "vtol" => "defaults/default_vtol.png", // Tracked is MegaMek's generic ground vehicle. It has wheeled, hover // and naval too, but which one a machine is is its motive type and // the result document does not carry it. "vehicle" => "defaults/default_tracked.png", _ => return None, })}
/// MegaMek's own constants, as the result document reports them.const TEAM_NONE: i64 = 0;const PLAYER_NONE: i64 = -1;
/// The seats MegaMek holds for us rather than for a player: the watcher that/// spools turn reports, and the seat a browser takes in a match nobody is/// playing. Dropped by name, and by never having owned a unit.////// **Not** by the `observer` flag, which cannot be used for this. MegaMek/// sets it on any player holding no entities outside the lounge, so a force/// that was wiped out ends the match flagged as a watcher - and arena records/// each seat as the game last described it. A side that lost every machine is/// the loser, not a spectator, and dropping it leaves a decided match with no/// loser in it to name.const WATCHERS: [&str; 2] = ["ArenaObserver", "ArenaSpectator"];
struct Force { name: String, /// Whether Princess flew it. Bot seats fight under the site's own handle, /// which is a person's handle too. bot: bool, /// The manifest's slot key, which is what the match staged this force's /// camo under. slot: String, team: i64, id: i64, units_remaining: i64, units_start: i64, bv_remaining: i64, bv_start: i64, /// Each machine as arena recorded it at victory, in the order the game /// held them. Empty for a result from an arena older than `UnitReport` - /// the page is the same page without them, one section shorter. units: Vec<serde_json::Value>,}
struct Summary { round: i64, forces: Vec<Force>, /// The winning team, or None where the document records none. victory_team: Option<i64>, /// The winning player id, for the single-player win MegaMek records /// instead of a team one. victory_player: Option<i64>,}
fn int(value: Option<&serde_json::Value>, fallback: i64) -> i64 { value .and_then(serde_json::Value::as_i64) .unwrap_or(fallback)}
fn summarise(result: &serde_json::Value) -> Option<Summary> { // Which seats Princess flew, from the identity map arena attaches to the // result. A bot fights under the site's own handle - which is also a real // account - so this is the only thing that tells the two apart. let bot_slots: Vec<&str> = result .get("identity") .and_then(|identity| identity.get("players")) .and_then(serde_json::Value::as_array) .map(|players| { players .iter() .filter(|seat| { seat.get("control").and_then(serde_json::Value::as_str) != Some("human") }) .filter_map(|seat| seat.get("slot").and_then(serde_json::Value::as_str)) .collect() }) .unwrap_or_default(); let players = result.get("players")?.as_array()?; let mut forces = Vec::new(); for player in players { let name = player .get("name") .and_then(serde_json::Value::as_str) .unwrap_or("") .to_owned(); let slot = player.get("slot").and_then(serde_json::Value::as_str); if WATCHERS.contains(&name.as_str()) || slot.is_some_and(|s| WATCHERS.contains(&s)) { continue; } // A seat that never owned a unit did not fight, whatever it is called. let units_start = int(player.get("unitsStart"), 0); if units_start <= 0 { continue; } forces.push(Force { bot: slot.is_some_and(|s| bot_slots.contains(&s)), name, slot: player .get("slot") .and_then(serde_json::Value::as_str) .unwrap_or_default() .to_owned(), team: int(player.get("team"), 0), id: int(player.get("id"), PLAYER_NONE), units_remaining: int(player.get("unitsRemaining"), 0), units_start, bv_remaining: int(player.get("bvRemaining"), 0), bv_start: int(player.get("bvStart"), 0), units: player .get("units") .and_then(serde_json::Value::as_array) .cloned() .unwrap_or_default(), }); } if forces.is_empty() { return None; } let team = int(result.get("victoryTeam"), TEAM_NONE); let player = int(result.get("victoryPlayerId"), PLAYER_NONE); Some(Summary { round: int(result.get("round"), 0), forces, victory_team: (team != TEAM_NONE).then_some(team), victory_player: (player != PLAYER_NONE).then_some(player), })}
impl Summary { /// Whether this force won, as the document recorded it rather than as the /// numbers imply: a forced victory leaves the loser's units standing, so /// counting them gets that case exactly backwards. fn won(&self, force: &Force) -> bool { match (self.victory_team, self.victory_player) { (Some(team), _) => force.team == team, (None, Some(id)) => force.id == id, _ => false, } }
fn decided(&self) -> bool { self.victory_team.is_some() || self.victory_player.is_some() }
/// The headline: who beat whom, or that nobody did. fn title(&self) -> String { let winners: Vec<&str> = self .forces .iter() .filter(|f| self.won(f)) .map(|f| f.name.as_str()) .collect(); let losers: Vec<&str> = self .forces .iter() .filter(|f| !self.won(f)) .map(|f| f.name.as_str()) .collect(); if !self.decided() || winners.is_empty() || losers.is_empty() { return format!("{} - a drawn match", self.line(" vs ")); } format!("{} beat {}", winners.join(" and "), losers.join(" and ")) }
fn line(&self, joiner: &str) -> String { self.forces .iter() .map(|f| f.name.as_str()) .collect::<Vec<&str>>() .join(joiner) }
/// What is left of each side, in one sentence. /// The sentence under the card in a feed. /// /// Read out loud rather than tabulated: this is the line everyone who /// scrolls past sees, and "@a: 1/1 units, 1639/1842 BV" is a log entry. /// Battle value as the fraction it is, because "89% of its battle value /// standing" says how the fight went and 1639 says nothing without the /// other number beside it. fn description(&self) -> String { let parts: Vec<String> = self .forces .iter() .map(|f| { let share = if f.bv_start > 0 { f.bv_remaining * 100 / f.bv_start } else { 0 }; format!( "{} finished with {} of {} machine{} standing and {share}% of their battle value", f.name, f.units_remaining, f.units_start, if f.units_start == 1 { "" } else { "s" }, ) }) .collect(); format!("Decided in round {}. {}.", self.round, parts.join(". ")) }}
/// Where each location sits on its machine, for the grid below.////// A body plan per class, because they are not the same shape and a/// location's place on the diagram is information: a Mek's arms flank its/// torsos, and a tank is a cross - front over body, sides either side of it,/// rear underneath. MegaMek's own abbreviations, laid out the way a record/// sheet lays them out.fn place_of(abbr: &str, sheet: Sheet) -> Option<&'static str> { Some(match sheet { // Turrets sit between the body and the rear rather than in the middle // of the cross: a vehicle can carry two, and neither is a face of the // hull. Sheet::Vehicle => match abbr { "FR" => "fr", "LS" => "ls", "BD" => "bd", "RS" => "rs", "TU" => "tu", "FT" => "ft", "RR" => "rr", _ => return None, }, // Head over the torsos, arms beside them, legs under. A quad's front // legs stand where a biped's arms do. _ => match abbr { "HD" => "hd", "CT" => "ct", "LT" => "lt", "RT" => "rt", "LA" | "FLL" => "la", "RA" | "FRL" => "ra", "LL" | "RLL" => "ll", "RL" | "RRL" => "rl", _ => return None, }, })}
/// One bar and the pair of numbers behind it.////// The numbers are printed because a bar cannot be read to a pip, and a/// record sheet is counted rather than eyeballed.fn meter(label: &str, left: i64, max: i64, kind: &str) -> String { let width = if max > 0 { (left * 100 / max).clamp(0, 100) } else { 0 }; let hurt = if max > 0 && left < max { " hurt" } else { "" }; format!( "<div class=\"meter {kind}{hurt}\"><span class=\"mlabel\">{label}</span>\ <span class=\"mtrack\"><span style=\"width:{width}%\"></span></span>\ <span class=\"mfig\">{left}/{max}</span></div>" )}
/// What the machine had left to shoot with.////// Bins rather than a total: two bins of the same type are two different/// amounts, and a machine that walked away with one round in one bin is a/// different story from one that walked away loaded.fn ammo(unit: &serde_json::Value) -> String { let Some(bins) = unit.get("ammo").and_then(serde_json::Value::as_array) else { return String::new(); }; if bins.is_empty() { return String::new(); } let mut rows = String::new(); for bin in bins { let shots = int(bin.get("shots"), 0); let max = int(bin.get("shotsMax"), 0); let blown = bin.get("destroyed").and_then(serde_json::Value::as_bool) == Some(true); let note = if blown { " <span class=\"gone\">destroyed</span>" } else if shots == 0 { " <span class=\"gone\">dry</span>" } else { "" }; rows.push_str(&format!( "<li><span class=\"aname\">{name}</span>\ <span class=\"aloc\">{loc}</span>\ <span class=\"ashots\">{shots}/{max}</span>{note}</li>", name = escape( bin.get("name") .and_then(serde_json::Value::as_str) .unwrap_or("Ammo") ), loc = escape( bin.get("loc") .and_then(serde_json::Value::as_str) .unwrap_or("") ), )); } format!("<h3>Ammunition</h3><ul class=\"ammo\">{rows}</ul>")}
/// Which record sheet a machine gets.////// A class of machine is a class of paper: a Mek's sheet is an armour diagram/// and twelve numbered critical slots per location, a vehicle's is a diagram/// and a table of the things that go wrong with vehicles, a platoon's is a/// count of the people in it. Anything else gets [`Sheet::Plain`], which says/// so rather than drawing a Mek's shape around a machine that is not one.#[derive(Clone, Copy, PartialEq)]enum Sheet { Mek, Vehicle, Infantry, Plain,}
fn sheet_class(unit: &serde_json::Value) -> Sheet { match unit.get("kind").and_then(serde_json::Value::as_str) { Some("mek") => Sheet::Mek, Some("vehicle") | Some("vtol") => Sheet::Vehicle, Some("infantry") | Some("battle armor") => Sheet::Infantry, _ => Sheet::Plain, }}
/// The numbered critical slots of one location, the way the paper sheet/// prints them: every line in its place, empties included, damage struck/// through where it landed.////// Drawn inside the location's own box on the diagram, which is where a/// record sheet puts it - the head's six lines are printed in the head. The/// tally this replaces ("11 of 12 slots") said how many slots went and never/// which, and which is what a reader opens a sheet for.////// Meks only. A vehicle has twenty-five slots a location and four of them/// filled, so printing them all would be a hundred and twenty-five lines of/// "Roll again" - which is why `Sheet::Vehicle` lists equipment instead.fn crit_column(location: &serde_json::Value) -> String { let Some(slots) = location.get("slots").and_then(serde_json::Value::as_array) else { return String::new(); }; if slots.is_empty() { return String::new(); } let mut rows = String::new(); for (n, slot) in slots.iter().enumerate() { if slot.is_null() { rows.push_str(&format!( "<li class=\"empty\"><span class=\"n\">{}</span>Roll again</li>", n + 1 )); continue; } let state = if slot.get("missing").and_then(serde_json::Value::as_bool) == Some(true) { "missing" } else if slot.get("destroyed").and_then(serde_json::Value::as_bool) == Some(true) { "destroyed" } else if slot.get("breached").and_then(serde_json::Value::as_bool) == Some(true) { "breached" } else if slot.get("hit").and_then(serde_json::Value::as_bool) == Some(true) { "hit" } else { "ok" }; rows.push_str(&format!( "<li class=\"{state}\"><span class=\"n\">{n}</span>{name}</li>", n = n + 1, name = escape( slot.get("name") .and_then(serde_json::Value::as_str) .unwrap_or("Equipment") ), )); } format!("<ol class=\"critlist\">{rows}</ol>")}
/// Everything the machine carries, with what is left in it.////// The one section every class gets, because it is the one that does not/// depend on the shape of the machine - and for a class with no sheet of its/// own it is most of what there is to say.////// Identical items in the same place are counted rather than repeated, the/// way a record sheet prints them: a Griffin carries twelve heat sinks and/// "Heat Sink" written twelve times is a list nobody reads. Damage breaks the/// grouping, because a destroyed launcher and a working one are not the same/// line - which is the point of the section.fn equipment(unit: &serde_json::Value) -> String { let Some(items) = unit.get("equipment").and_then(serde_json::Value::as_array) else { return String::new(); }; if items.is_empty() { return String::new(); }
struct Row { name: String, loc: String, state: &'static str, shots: String, count: usize, }
let mut rows: Vec<Row> = Vec::new(); for item in items { let destroyed = item.get("destroyed").and_then(serde_json::Value::as_bool) == Some(true); let hit = item.get("hit").and_then(serde_json::Value::as_bool) == Some(true); let state = if destroyed { "destroyed" } else if hit { "hit" } else { "" }; // Ammunition is never grouped: two bins of the same type hold two // different amounts, and adding them up hides an empty one. let shots = match ( item.get("shots").and_then(serde_json::Value::as_i64), item.get("shotsMax").and_then(serde_json::Value::as_i64), ) { (Some(left), Some(max)) if max > 0 => format!("{left}/{max}"), _ => String::new(), }; let name = item .get("name") .and_then(serde_json::Value::as_str) .unwrap_or("Equipment") .to_owned(); let loc = item .get("loc") .and_then(serde_json::Value::as_str) .unwrap_or("") .to_owned(); let groupable = shots.is_empty(); match rows.last_mut() { Some(last) if groupable && last.name == name && last.loc == loc && last.state == state && last.shots.is_empty() => { last.count += 1; } _ => rows.push(Row { name, loc, state, shots, count: 1, }), } }
let body: String = rows .iter() .map(|row| { let count = if row.count > 1 { format!(" <span class=\"ecount\">x{}</span>", row.count) } else { String::new() }; format!( "<li class=\"{state}\"><span class=\"ename\">{name}{count}</span>\ <span class=\"eloc\">{loc}</span><span class=\"eshots\">{shots}</span></li>", state = row.state, name = escape(&row.name), // An em dash rather than a gap: equipment with no location is // built into the machine - a Griffin's engine heat sinks are // ten of them - and a blank column reads as missing data. loc = if row.loc.is_empty() { "—".to_owned() } else { escape(&row.loc) }, shots = escape(&row.shots), ) }) .collect(); format!("<h3>Weapons and equipment</h3><ul class=\"equip\">{body}</ul>")}
/// What went wrong with a vehicle, which is mostly not in its slots: a locked/// turret, a wrecked drive train, a hit driver.fn vehicle_damage(unit: &serde_json::Value) -> String { let Some(tank) = unit.get("vehicle") else { return String::new(); }; if tank.is_null() { return String::new(); } let mut said: Vec<String> = Vec::new(); if tank .get("turretLocked") .and_then(serde_json::Value::as_bool) == Some(true) { said.push("Turret locked".to_owned()); } let motive = int(tank.get("motiveDamage"), 0); if motive > 0 { said.push(format!("Motive damage {motive}")); } if tank.get("driverHit").and_then(serde_json::Value::as_bool) == Some(true) { said.push("Driver hit".to_owned()); } if tank .get("commanderHit") .and_then(serde_json::Value::as_bool) == Some(true) { said.push("Commander hit".to_owned()); } if let Some(stabs) = tank .get("stabilisers") .and_then(serde_json::Value::as_array) { for loc in stabs { if let Some(abbr) = loc.as_str() { said.push(format!("Stabiliser hit: {}", escape(abbr))); } } } let body = if said.is_empty() { // Said out loud. An empty section reads as "we did not look". "<li class=\"ok\">None</li>".to_owned() } else { said.into_iter() .map(|line| format!("<li class=\"destroyed\">{line}</li>")) .collect() }; format!("<h3>Vehicle damage</h3><ul class=\"vdamage\">{body}</ul>")}
/// A platoon's armour is its people.fn infantry_strength(unit: &serde_json::Value) -> String { let Some(troops) = unit.get("infantry") else { return String::new(); }; if troops.is_null() { return String::new(); } let left = int(troops.get("troopers"), 0); let max = int(troops.get("troopersMax"), 0); if max <= 0 { return String::new(); } format!( "<h3>Troopers</h3><p class=\"troopers\">{left} of {max} still standing</p>{}", meter("T", left, max, "armor") )}
/// One machine's record sheet: every location, what covered it, what held it/// up, and what was in it.fn sheet(unit: &serde_json::Value, slots: bool) -> String { let plan = sheet_class(unit); let empty = Vec::new(); let locations = unit .get("locations") .and_then(serde_json::Value::as_array) .unwrap_or(&empty); let mut tiles = String::new(); for location in locations { let abbr = location .get("abbr") .and_then(serde_json::Value::as_str) .unwrap_or("?"); let internal = int(location.get("internal"), 0); let internal_max = int(location.get("internalMax"), 0); let blown = location .get("blownOff") .and_then(serde_json::Value::as_bool) == Some(true); let gone = internal_max > 0 && internal <= 0; let class = if blown { "loc off" } else if gone { "loc gone" } else { "loc" }; let style = match place_of(abbr, plan) { Some(place) => format!(" style=\"grid-area:{place}\""), None => String::new(), }; let state = if blown { "<span class=\"lstate\">blown off</span>" } else if gone { "<span class=\"lstate\">destroyed</span>" } else { "" }; let rear = match int(location.get("rearMax"), 0) { max if max > 0 => meter("R", int(location.get("rear"), 0), max, "armor"), _ => String::new(), }; tiles.push_str(&format!( "<div class=\"{class}\"{style} title=\"{name}\">\ <p class=\"labbr\">{abbr}{state}</p>{armor}{rear}{structure}{crits}</div>", name = escape( location .get("name") .and_then(serde_json::Value::as_str) .unwrap_or(abbr) ), abbr = escape(abbr), armor = meter( "A", int(location.get("armor"), 0), int(location.get("armorMax"), 0), "armor" ), structure = meter("S", internal, internal_max, "structure"), // Only where the sheet under it lists them. A vehicle carries // twenty-five slots a location with four of them filled, and a // strip of twenty-one empty boxes says nothing at all. // The slots, printed in the box they sit in. A vehicle's box // gets none: twenty-five lines a location with four of them // holding anything is not a record sheet, it is a wall. crits = if slots { crit_column(location) } else { String::new() }, )); } // A body plan the grid knows, or a plain flow of whatever locations the // machine has. Forcing an unknown shape into the Mek grid would stack // tiles on top of each other. let known = locations.iter().any(|location| { place_of( location .get("abbr") .and_then(serde_json::Value::as_str) .unwrap_or(""), plan, ) .is_some() }); let shape = match (known, plan) { (true, Sheet::Vehicle) => "sheet veh", (true, _) => "sheet mek", (false, _) => "sheet flow", }; format!("<div class=\"{shape}\">{tiles}</div>")}
/// The crew, in one line: skills first, because battle value is computed from/// them, and what happened to them after.fn crew_line(unit: &serde_json::Value) -> String { let Some(crew) = unit.get("crew") else { return String::new(); }; let mut parts: Vec<String> = Vec::new(); if let Some(name) = crew.get("name").and_then(serde_json::Value::as_str) && !name.is_empty() { parts.push(escape(name)); } let gunnery = crew.get("gunnery").and_then(serde_json::Value::as_i64); let piloting = crew.get("piloting").and_then(serde_json::Value::as_i64); if let (Some(g), Some(p)) = (gunnery, piloting) { parts.push(format!("{g}/{p}")); } if crew.get("dead").and_then(serde_json::Value::as_bool) == Some(true) { parts.push("killed".to_owned()); } else if crew.get("ejected").and_then(serde_json::Value::as_bool) == Some(true) { parts.push("ejected".to_owned()); } else { let hits = int(crew.get("hits"), 0); if hits > 0 { parts.push(format!("{hits} hit{}", if hits == 1 { "" } else { "s" })); } } parts.join(" · ")}
/// What became of a machine, said the way a person would say it. The words/// are `suramadu/web/units.js`'s, because the two screens read the same/// document and should not describe it differently. Anything unrecognised is/// printed as MegaMek recorded it.fn status_word(status: &str) -> &str { match status { // "active" is MegaMek's word for a unit still on the board, and the // board is gone by the time anyone reads this page. "active" => "standing", "retreated" => "withdrew", "pushed" => "pushed off", other => other, }}
/// How hurt a machine is, in a word.////// MegaMek's own `getDamageLevel`, which is a rules judgement about what the/// machine can still do rather than an average of its armour - a crippled Mek/// can be carrying most of its plate. Status wins where there is one: a/// machine that was destroyed is destroyed, whatever it was carrying a moment/// earlier.fn condition(unit: &serde_json::Value) -> String { let status = unit .get("status") .and_then(serde_json::Value::as_str) .unwrap_or("active"); if status != "active" { return status_word(status).to_owned(); } match unit.get("damage").and_then(serde_json::Value::as_str) { Some("crippled") => "crippled", Some("heavy") => "heavy damage", Some("moderate") => "damaged", Some("light") => "light damage", // Said out loud rather than left blank: a machine that came through a // fight untouched is the most interesting card on the screen. _ => "unhurt", } .to_owned()}
/// Which of the states the tile is painted in. Separate from the words above,/// so the colour follows the meaning rather than the phrasing: a withdrawn/// machine is intact and a crippled one is not.fn tone(unit: &serde_json::Value) -> &'static str { let status = unit .get("status") .and_then(serde_json::Value::as_str) .unwrap_or("active"); if lost(status) { return "lost"; } if status != "active" { return "gone"; } match unit.get("damage").and_then(serde_json::Value::as_str) { Some("crippled") => "crippled", Some("heavy") => "heavy", Some("moderate") => "moderate", Some("light") => "light", _ => "whole", }}
/// A machine that is not coming back.fn lost(status: &str) -> bool { matches!(status, "destroyed" | "devastated" | "salvage" | "captured")}
/// The sprite a machine is drawn with: its design's own where the index/// carries it, and MegaMek's default for its class where it does not.////// One rule, because the page and the card have to agree about what a/// machine looks like - they are the same report, and one of them unfurls/// into the other.fn picture_for( unit: &serde_json::Value, units: Option<&crate::units::UnitIndex>,) -> Option<String> { let name = unit.get("name").and_then(serde_json::Value::as_str); let index = units?; let sprite = name .and_then(|name| index.design(name)) .map(str::to_owned) .or_else(|| sprite_for(unit).map(str::to_owned))?; Some(sprite)}
/// One machine on the force card: its picture, its name, and how hurt it is./// The whole tile is the link into its record sheet.////// This is the view a reader starts on, and for most readers it is the only/// one they need - which of these machines came home, and in what state. The/// sheet behind it is for the reader who wants to know why.////// The picture is the design's own where the index knows it, which is every/// Mek, and its weight class otherwise. That is the difference between a/// Thunderbolt and a Crusader reading as two machines and reading as the same/// 65-tonne biped twice.fn unit_tile( id: &str, unit: &serde_json::Value, units: Option<&crate::units::UnitIndex>,) -> String { let name = unit .get("name") .and_then(serde_json::Value::as_str) .unwrap_or("Unknown machine"); let mut figures: Vec<String> = Vec::new(); if let Some(tons) = unit.get("tons").and_then(serde_json::Value::as_f64) && tons > 0.0 { figures.push(format!("{tons:.0} t")); } let bv = int(unit.get("bv"), 0); if bv > 0 { figures.push(format!("{bv} BV")); } let armour = int(unit.get("armorPercent"), 0).clamp(0, 100); let status = unit .get("status") .and_then(serde_json::Value::as_str) .unwrap_or("active"); // No bar on a wreck: nought per cent of the armour of a machine that is // not there any more is not a fact about the fight. let bar = if lost(status) { String::new() } else { format!("<span class=\"ubar\"><span style=\"width:{armour}%\"></span></span>") }; // MegaMek's own art, served from the site beside the release it belongs // to. Nothing when this deployment names no index, since the release the // art is published under is the index's own - and a machine with no // picture is drawn as its name and its condition rather than as a broken // image. let picture = picture_for(unit, units) .and_then(|sprite| units?.sprite_url(&sprite)) .map(|url| { format!( "<img class=\"uicon\" src=\"{}\" alt=\"\" width=\"84\" height=\"72\">", escape(&url) ) }) .unwrap_or_default(); format!( "<a class=\"utile {tone}\" href=\"#{id}\">{picture}\ <span class=\"uname\">{name}</span>\ <span class=\"ufig\">{figures}</span>\ <span class=\"ustate\">{condition}</span>{bar}</a>", tone = tone(unit), picture = picture, name = escape(name), figures = figures.join(" · "), condition = escape(&condition(unit)), )}
/// The record sheet, as a panel that is only on screen when it is asked for.////// `:target` and not a script: the reader is whoever the link was sent to,/// this page is served to them signed out, and a modal that needs JavaScript/// to open is a modal some of them cannot open. The address bar carries which/// machine is open, so the back button closes it and a link can be sent/// straight to one machine's sheet.fn unit_modal(id: &str, unit: &serde_json::Value) -> String { let name = unit .get("name") .and_then(serde_json::Value::as_str) .unwrap_or("Unknown machine"); let mut facts: Vec<String> = Vec::new(); facts.push(escape(&condition(unit))); let crew = crew_line(unit); if !crew.is_empty() { facts.push(format!("Pilot {crew}")); } // Heat, for a machine that tracks it. MegaMek gives a vehicle a capacity // of 999, which is its way of saying the rules do not apply here; printed // as it stands it reads as a fact about the fight. let heat = int(unit.get("heat"), 0); let heat_max = int(unit.get("heatCapacity"), 0); if heat_max > 0 && heat_max < 100 { facts.push(format!("Heat {heat}/{heat_max}")); } // What this class of machine's sheet is made of. The armour diagram is // common to the three that have one; what sits under it is not. let class = sheet_class(unit); let body = match class { Sheet::Mek => format!("{}{}", sheet(unit, true), equipment(unit)), Sheet::Vehicle => format!( "{}{}{}", sheet(unit, false), vehicle_damage(unit), equipment(unit) ), Sheet::Infantry => format!("{}{}", infantry_strength(unit), equipment(unit)), // The fallback, and it says what it is. An aerospace fighter drawn // into a Mek's body plan would be a lie told neatly; this is the // armour it had, the gear it carried, and an honest note that nobody // has drawn this class its own sheet yet. Sheet::Plain => format!( "<p class=\"note\">No record sheet for this class of machine yet - \ what follows is its armour and what it was carrying.</p>{}{}", sheet(unit, false), equipment(unit) ), }; format!( "<div class=\"modal\" id=\"{id}\"><div class=\"mbox\">\ <a class=\"mclose\" href=\"#forces\" aria-label=\"Close\">×</a>\ <h2>{name}</h2><p class=\"mfacts\">{facts}</p>{body}{ammo}</div>\ <a class=\"mback\" href=\"#forces\" tabindex=\"-1\" aria-hidden=\"true\"></a></div>", name = escape(name), facts = facts.join(" · "), ammo = if class == Sheet::Infantry { String::new() } else { ammo(unit) }, )}
/// The address of one machine's sheet: which force, and which machine in it./// Stable for the life of the page, because a reader can send it to somebody.fn unit_id(force: usize, unit: usize) -> String { format!("u{force}-{unit}")}
/// The share block: the cards this match can post, one at a time, and the/// composer aimed at whichever is showing.////// A gallery rather than a list of names, because the thing being chosen is a/// picture. Every card this match can draw gets a slide with its own picture,/// its own permalink and its own composer, so what is on screen and what/// would be posted are the same thing.////// No script: the slides are a radio group, and the arrows and dots are/// labels for it. `:target` would be the shorter markup, but following a/// fragment scrolls the gallery to the top of the viewport on every press -/// the page jumps where only the card should turn. A radio changes nothing/// but its own state.////// The consequence worth knowing is that turning the gallery does not change/// this page's own og:image, which is fixed at render. It does not have to -/// each slide's post links to that card's address, and the unfurler reads the/// card from there.fn share_menu(page: &Page) -> String { // A reader who arrived from somebody's post is not posting it again. The // composer belongs to the player the match handed here, and nothing else // on this page depends on it. if !page.participant { return String::new(); } let slides: Vec<(card::Layout, String, String, String)> = page .offered .iter() .map(|layout| { let query = card_query(page.perspective, Some(*layout)); ( *layout, format!("{}{}", page.base, query), slide_id(*layout), pick_id(*layout), ) }) .collect();
// The one whose radio is checked at render, and so the one showing // before anything is pressed. let opening = page .offered .iter() .position(|layout| *layout == page.chosen) .unwrap_or(0);
let mut out = String::from("<div class=\"share\">"); for (index, (layout, address, id, pick)) in slides.iter().enumerate() { let previous = &slides[(index + slides.len() - 1) % slides.len()].3; let next = &slides[(index + 1) % slides.len()].3; let opens = if index == opening { " checked" } else { "" }; let turn = if slides.len() > 1 { format!( "<label class=\"turn back\" for=\"{previous}\" \ aria-label=\"The card before this one\">\u{276e}</label>\ <label class=\"turn on\" for=\"{next}\" \ aria-label=\"The card after this one\">\u{276f}</label>", previous = escape(previous), next = escape(next), ) } else { String::new() }; let dots: String = slides .iter() .map(|(other_layout, _, other, other_pick)| { format!( "<label class=\"dot{here}\" for=\"{other_pick}\" \ aria-label=\"{label}\"></label>", here = if other == id { " here" } else { "" }, other_pick = escape(other_pick), label = escape(other_layout.label()), ) }) .collect(); let compose = compose_url(address);
out.push_str(&format!( "<input class=\"pickr\" type=\"radio\" name=\"card\" id=\"{pick}\"{opens} \ aria-label=\"{label}\">\ <div class=\"slide\" id=\"{id}\">\ <div class=\"framed\">\ <img class=\"cardshot\" src=\"{picture}\" loading=\"lazy\" \ alt=\"The {label} card, which is what this link unfurls with.\">\ {turn}</div>\ <div class=\"pick\"><span class=\"named\">{label}</span>{dots}</div>\ <div class=\"sharerow\">\ <a class=\"post\" href=\"{compose}\" target=\"_blank\" rel=\"noopener\">\ Post report to Bluesky</a>\ </div></div>", pick = escape(pick), id = escape(id), picture = escape(&format!( "{}/card.png{}", page.base, card_query(page.perspective, Some(*layout)) )), label = escape(layout.label()), compose = escape(&compose), )); } out.push_str("</div>"); out}
/// The id one card's slide carries.fn slide_id(layout: card::Layout) -> String { format!("card-{}", layout.slug())}
/// The id of the radio that shows one card's slide, and so what the arrows/// and dots on every other slide are labels for.fn pick_id(layout: card::Layout) -> String { format!("pick-{}", layout.slug())}
/// The query that names a point of view and a card.fn card_query(perspective: Option<&Perspective>, layout: Option<card::Layout>) -> String { let mut parts: Vec<String> = Vec::new(); if let Some(perspective) = perspective { parts.push(format!("perspective={}", urlencode(&perspective.handle))); } if let Some(layout) = layout { parts.push(format!("card={}", layout.slug())); } if parts.is_empty() { String::new() } else { format!("?{}", parts.join("&")) }}
/// The Bluesky composer, prefilled with one address.fn compose_url(here: &str) -> String { // The link and nothing else. Everything a sentence here could say - who // won, what was played, which round - is already drawn on the card the // link unfurls into, and saying it twice reads as a caption on a caption. // // The link has to be in the text: `text` is the only parameter the intent // takes, and the composer builds the card from a URL it finds there. A // post with an empty body and only the card is reachable from here, by // deleting the link once the card attaches - which Bluesky keeps. format!("https://bsky.app/intent/compose?text={}", urlencode(here))}
/// Percent-encoding for a query value. Hand-rolled for the same reason the/// rest of this file is: one call site, and no reason to reach for a crate.pub(crate) fn urlencode(text: &str) -> String { let mut out = String::with_capacity(text.len() * 3); for byte in text.as_bytes() { match byte { b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { out.push(*byte as char) } _ => out.push_str(&format!("%{byte:02X}")), } } out}
/// Text into HTML. Everything interpolated into the page below goes through/// this: a handle is a name someone else chose, and it reaches here from a/// result document written inside a match container.pub(crate) fn escape(text: &str) -> String { let mut out = String::with_capacity(text.len()); for c in text.chars() { match c { '&' => out.push_str("&"), '<' => out.push_str("<"), '>' => out.push_str(">"), '"' => out.push_str("""), '\'' => out.push_str("'"), _ => out.push(c), } } out}
/// Why a report cannot be shown. A report address gets visited with nothing/// behind it - a link shared mid-match, a mistyped id - and a bare 404 is a/// blank page.#[derive(Clone, Copy, Debug, PartialEq)]enum Missing { /// No such match, or none this deployment launched. Unknown, /// Launched, and no result yet because it is not over yet. Unfinished, /// Over, and no result that can be shown. The container failed, or died /// between deciding the match and uploading what it decided. Resultless, /// Whether there is a report could not be determined - the database did /// not answer, or this deployment has no artifact store. Says nothing /// about the match, so it must not be cached as an absence. Unreadable,}
impl Missing { /// From the row's status. No container poll: this page is public, and /// saying "not yet" should not cost an AWS call. fn read(status: &str) -> Self { match status { "starting" | "running" | "ready" => Missing::Unfinished, _ => Missing::Resultless, } }
fn heading(self) -> &'static str { match self { Missing::Unknown => "No such match", Missing::Unfinished => "This match is still being fought", Missing::Resultless => "This match ended without a result", Missing::Unreadable => "This report cannot be read right now", } }
/// The line under it. fn line(self) -> &'static str { match self { Missing::Unknown => { "The link may be mistyped, or the match may have been launched somewhere else." } Missing::Unfinished => { "The report is written when the match is decided. This page will look again on its own." } Missing::Resultless => "Nothing was recorded for it, so there is no report to show.", Missing::Unreadable => "Something went wrong looking it up. This page will try again.", } }
/// Whether the page reloads itself. Only where waiting helps: the other /// two never grow a result. fn refresh(self) -> bool { matches!(self, Missing::Unfinished | Missing::Unreadable) }
/// Whether a report may still appear at this address. /// /// Unknown is the only permanent one. Resultless is not: the container /// retries the upload on its way out, so a match that is over without a /// result can gain one a minute later. Unreadable is not even an answer /// about the match - caching it would blank a live report for as long as /// it was held. fn may_yet(self) -> bool { !matches!(self, Missing::Unknown) }
/// What to answer a machine with. /// /// 503 rather than 404 wherever a report may still appear, for two /// reasons. A card service that fetches a 404 records "this link has no /// preview" and a post made from it carries none, permanently - where a /// failure leaves the link bare and the next fetch gets the real card. /// And the CDN rewrites 404 distribution-wide into the site's static /// 404.html, so the page below would never reach a reader at all. fn status(self) -> StatusCode { if self.may_yet() { StatusCode::SERVICE_UNAVAILABLE } else { StatusCode::NOT_FOUND } }}
/// The page at a report address with no report behind it. No og:image: the/// only one it could name is the card, which is refusing too - see `status`/// for what a machine gets and why.fn notice(missing: Missing, home: &str) -> String { let refresh = if missing.refresh() { "<meta http-equiv=\"refresh\" content=\"20\">\n" } else { "" }; format!( "<!doctype html>\n<html lang=\"en\">\n<head>\n\ <meta charset=\"utf-8\">\n\ <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n\ <meta name=\"robots\" content=\"noindex\">\n\ {refresh}\ <title>{heading} - lance.blue</title>\n\ <meta property=\"og:site_name\" content=\"lance.blue\">\n\ <meta property=\"og:title\" content=\"{heading}\">\n\ <meta property=\"og:description\" content=\"{line}\">\n\ <style>{STYLE}</style>\n\ </head>\n<body>\n\ <main>\n\ <p class=\"eyebrow\">Match report</p>\n\ <h1>{heading}</h1>\n\ <p class=\"round\">{line}</p>\n\ <p class=\"cta\"><a class=\"play\" href=\"{home}/#matches\">Play lance.blue</a>\ <span class=\"ctanote\">{tagline}</span></p>\n\ <p class=\"out\"><a href=\"{home}\">lance.blue</a></p>\n\ </main>\n</body>\n</html>\n", heading = missing.heading(), line = missing.line(), home = escape(home), tagline = TAGLINE, refresh = refresh, STYLE = STYLE, )}
/// A refusal with something to read.////// Never cached where a report may still appear: a cached refusal outlives/// the thing it describes, and every reader behind that cache - the CDN, a/// card service, a browser - keeps being told there is nothing here after/// there is.fn refuse(missing: Missing, home: &str) -> Response { ( missing.status(), refusal_headers(missing), Html(notice(missing, home)), ) .into_response()}
/// The same refusal for the card, which is an image and has no page to show.fn refuse_card(missing: Missing) -> Response { (missing.status(), refusal_headers(missing)).into_response()}
fn refusal_headers(missing: Missing) -> HeaderMap { let mut headers = HeaderMap::new(); if missing.may_yet() { headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); headers.insert(header::RETRY_AFTER, HeaderValue::from_static("30")); } else { headers.insert( header::CACHE_CONTROL, HeaderValue::from_static("public, max-age=300"), ); } headers}
/// The result of a match, for anyone with the link.pub async fn page( State(state): State<AppState>, Path(id): Path<String>, axum::extract::Query(ask): axum::extract::Query<Ask>,) -> Response { // No artifact store: a deployment fault, and nothing about this match. let Some(matches) = state.matches.as_ref() else { return refuse(Missing::Unreadable, &state.web_origin); }; // The row first: its status is what tells the refusals apart. A lookup // that fails is not an absent match - answering 404 to a database hiccup // caches a live report as gone. let row = match state.db.get_match(&id).await { Ok(Some(row)) => row, Ok(None) => return refuse(Missing::Unknown, &state.web_origin), Err(_) => { tracing::error!(match_id = id, "report: match lookup failed"); return refuse(Missing::Unreadable, &state.web_origin); } }; // What it was and how it was reached. A match with no seats recorded is // ordinary; a lookup that failed is not, and reading it as "no seats" // would frame a daily challenge as a plain scenario and cache that for // an hour. let seats = match state.db.match_players(&id).await { Ok(seats) => seats, Err(_) => { tracing::error!(match_id = id, "report: seat lookup failed"); return refuse(Missing::Unreadable, &state.web_origin); } }; let context = Context::read(&row, &seats); let Some(result) = matches.result(&id).await else { return refuse(Missing::read(&row.status), &state.web_origin); }; // Present but unusable is still nothing to show. let Some(summary) = summarise(&result) else { tracing::warn!(match_id = id, "report: result present but not summarisable"); return refuse(Missing::Resultless, &state.web_origin); };
let perspective = Perspective::read(ask.perspective.as_deref()); // Asked rather than fetched: the board is megabytes and this only needs // to know whether the card drawn on it can be offered. let board = matches.has_board(&id).await; let offered = layouts(&context, &summary, perspective.is_some(), board); let chosen = ask .card .as_deref() .and_then(card::Layout::from_slug) .filter(|wanted| offered.contains(wanted)) .unwrap_or(offered[0]); // The address this page is at, ask and all: it is what an unfurler reads // and what a share link has to reproduce, so a card someone chose is the // card their post carries. // Where this page is *published*, which is not always where it is served // from: with a CDN routing /reports/ to this service, the address a // reader copies and an unfurler fetches is the site's. let base = format!( "{}/reports/{}", state .share_origin .as_deref() .unwrap_or(matches.public_url()), id ); let here = format!("{base}{}", ask.query()); // Drawn per match and per ask: the address carries the layout and the // point of view it was asked from, so the picture a post unfurls with is // the one the sharer chose. Never missing - an unfurler that fetches // nothing shows a bare link, which is what this page exists to avoid - // because a result that cannot be summarised has already been refused // above. let image = format!("{base}/card.png{}", ask.query());
( // A result never changes once it exists, so this is worth caching - // and the caching is what keeps an unfurler, a feed and everyone who // taps the post off the artifacts bucket. // // Short while the board is missing, for the same reason the card is: // it can still arrive from the container's exit pass, and it decides // whether this page offers the card drawn on it. [( header::CACHE_CONTROL, if board { "public, max-age=3600" } else { "public, max-age=60" }, )], Html(document( &summary, &context, &Page { here: &here, base: &base, image: &image, home: &state.web_origin, perspective: perspective.as_ref(), offered: &offered, chosen, participant: ask.participant.is_some(), units: state.units.as_deref(), }, )), ) .into_response()}
/// The page itself. Pure text in, pure text out: an AppState is not needed to/// decide what a finished match looks like, and a document that can be built/// without one can be tested and looked at without one too./// Where the page is, what it is showing, and for whom.struct Page<'a> { /// This page's own address, ask and all. here: &'a str, /// The same without the ask, for building links that carry a different /// one. base: &'a str, image: &'a str, home: &'a str, perspective: Option<&'a Perspective>, /// The cards this match can offer, and the one being shown. offered: &'a [card::Layout], chosen: card::Layout, /// Whether to offer the composer at all. See `Ask::participant`. participant: bool, /// The unit library, for a machine's own picture. None where this /// deployment has no index, and then every machine draws as its class. units: Option<&'a crate::units::UnitIndex>,}
fn document(summary: &Summary, context: &Context, page: &Page) -> String { let (here, image, home) = (page.here, page.image, page.home); // Named for whoever asked, when somebody did: a player posting their own // match should not have to write "@them beat @me". let title = verdict_line(summary, page.perspective); let description = summary.description(); let mut rows = String::new(); // Built alongside the list and appended after it: a :target panel that // lives inside the card it covers inherits that card's stacking, and // opens under it. let mut modals = String::new(); for (index, force) in summary.forces.iter().enumerate() { let share = if force.bv_start > 0 { force.bv_remaining * 100 / force.bv_start } else { 0 }; // Text, never a link. // // The match page a player reads links each handle to its account, // because everyone on that screen fought in the match. This page is // public and one of the names on it belongs to somebody who did not // choose to be shown to whoever the other player shares it with. // Naming them is unavoidable - it is their match too - but pointing an // audience at their account is not, so this page does not. let name = escape(&force.name); let mark = if summary.won(force) { " <span class=\"victor\">victor</span>" } else { "" }; let machines: String = force .units .iter() .enumerate() .map(|(n, unit)| unit_tile(&unit_id(index, n), unit, page.units)) .collect(); let machines = if machines.is_empty() { String::new() } else { format!("<div class=\"force\">{machines}</div>") }; modals.push_str( &force .units .iter() .enumerate() .map(|(n, unit)| unit_modal(&unit_id(index, n), unit)) .collect::<String>(), ); rows.push_str(&format!( "<li><p class=\"who\">{name}{mark}</p>\ <p class=\"stats\">Team {} - units {}/{} - BV {}/{} ({share}%)</p>\ <div class=\"bar\"><div style=\"width:{share}%\"></div></div>{machines}</li>", force.team, force.units_remaining, force.units_start, force.bv_remaining, force.bv_start, )); }
format!( "<!doctype html>\n<html lang=\"en\">\n<head>\n\ <meta charset=\"utf-8\">\n\ <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n\ <title>{title} - lance.blue</title>\n\ <meta name=\"description\" content=\"{description}\">\n\ <meta property=\"og:type\" content=\"article\">\n\ <meta property=\"og:site_name\" content=\"lance.blue\">\n\ <meta property=\"og:url\" content=\"{here}\">\n\ <meta property=\"og:title\" content=\"{title}\">\n\ <meta property=\"og:description\" content=\"{description}\">\n\ <meta property=\"og:image\" content=\"{image}\">\n\ <meta property=\"og:image:alt\" content=\"The battlefield at the end of the match.\">\n\ <meta name=\"twitter:card\" content=\"summary_large_image\">\n\ <meta name=\"twitter:image\" content=\"{image}\">\n\ <style>{STYLE}</style>\n\ </head>\n<body>\n\ <main>\n\ <p class=\"eyebrow\">{eyebrow}</p>\n\ <h1>{title}</h1>\n\ <p class=\"round\">{context_line}{context_sep}Round {round}</p>\n\ {share}\n\ <ul class=\"forces\" id=\"forces\">{rows}</ul>{modals}\n\ <p class=\"cta\"><a class=\"play\" href=\"{home}/#matches\">Play lance.blue</a>\ <span class=\"ctanote\">{tagline}</span></p>\n\ <p class=\"out\"><a href=\"{home}\">lance.blue</a>\ <span class=\"outsep\"> · </span>\ <a href=\"{survey}\">{survey_label}</a></p>\n\ </main>\n</body>\n</html>\n", survey = SURVEY_URL, survey_label = SURVEY_LABEL, title = escape(&title), description = escape(&description), here = escape(here), image = escape(image), round = summary.round, eyebrow = context.entry.eyebrow(), context_line = context.line(), context_sep = if context.line().is_empty() { "" } else { " · " }, share = share_menu(page), tagline = TAGLINE, rows = rows, modals = modals, home = escape(home), STYLE = STYLE, )}
/// The board at the end, for the link preview and for the page itself.////// Streamed through rather than redirected to a presigned URL: a card service/// that follows a redirect to a signed S3 address caches the signature, and/// the picture stops loading when it expires. This one is a plain image at a/// plain address.pub async fn card( State(state): State<AppState>, Path(id): Path<String>, axum::extract::Query(ask): axum::extract::Query<Ask>,) -> Response { let Some(matches) = state.matches.as_ref() else { return refuse_card(Missing::Unreadable); }; let row = match state.db.get_match(&id).await { Ok(Some(row)) => row, Ok(None) => return refuse_card(Missing::Unknown), Err(_) => { tracing::error!(match_id = id, "report card: match lookup failed"); return refuse_card(Missing::Unreadable); } }; // Gated on the result, so a card is only public once the page that shows // it is: it is a picture of how somebody's match went. let Some(result) = matches.result(&id).await else { return refuse_card(Missing::read(&row.status)); }; let Some(summary) = summarise(&result) else { return refuse_card(Missing::Resultless); }; let seats = match state.db.match_players(&id).await { Ok(seats) => seats, Err(_) => { tracing::error!(match_id = id, "report card: seat lookup failed"); return refuse_card(Missing::Unreadable); } }; let context = Context::read(&row, &seats); let perspective = Perspective::read(ask.perspective.as_deref());
// The board it was fought on, and each force's own colours. Both are // artifacts of the match; both are optional, and the card is drawn // without whichever is missing. let board = matches.board(&id).await; let mut camo = Vec::with_capacity(summary.forces.len()); // A slot with no colours staged is ordinary and draws plain. A read that // failed is not: it means this card is missing something it should have, // and that is a caching decision rather than a drawing one. let mut colours_unread = false; for force in &summary.forces { match matches.camo(&id, &force.slot).await { Ok(found) => camo.push(found), Err(_) => { colours_unread = true; camo.push(None); } } }
let offered = layouts(&context, &summary, perspective.is_some(), board.is_some()); let layout = ask .card .as_deref() .and_then(card::Layout::from_slug) .filter(|wanted| offered.contains(wanted)) .unwrap_or(offered[0]);
// Each design's own picture, fetched before anything is drawn: the // drawing kit is synchronous by design, and a machine the index does not // carry simply keeps the silhouette it had. let art = unit_art(state.units.as_deref(), &summary).await;
let verdict = verdict_line(&summary, perspective.as_ref()); let (title, sub) = headline(layout, &context, &verdict, summary.round); let designation = context.designation(layout); let brief = context.brief(); let drawn = card::draw(&compose( &summary, Drawn { layout, brief: &brief, eyebrow: &designation, verdict: verdict_word(&summary, perspective.as_ref()), title: &title, sub: &sub, perspective: perspective.as_ref(), camo: &camo, board: board.clone(), art: &art, }, ));
// How long this picture is allowed to stand. // // A day once it is whole, because a finished match never changes. A // minute while any of it is missing - the ground, or colours S3 did not // hand over. The ground is the common case: arena sends the result the instant // the game is decided and the board render about five seconds later, and // the player is on this page inside three - so the first card of every // match is asked for in that gap. Cached for a day, that boardless first // draw would be the picture every reader of the post ever sees. let complete = board.is_some() && !colours_unread; let Some(bytes) = drawn.or(board) else { // Nothing drawn and no board to fall back on. Not an answer about // this match, so it is not cached as one. tracing::error!(match_id = id, "report card: nothing to send"); return refuse_card(Missing::Unreadable); }; let caching = if complete { "public, max-age=86400" } else { "public, max-age=60" }; ( [ (header::CONTENT_TYPE, "image/png"), (header::CACHE_CONTROL, caching), ], bytes, ) .into_response()}
/// What a reader asked for: whose view, and which card.#[derive(serde::Deserialize, Default)]pub struct Ask { /// A handle, with or without its `@`. pub perspective: Option<String>, /// A layout's slug. pub card: Option<String>, /// Set by the hand-off out of a match, and by nothing else: it says the /// reader is the player who just fought, so the page offers them the /// composer. Deliberately absent from `query` below, so it reaches /// neither the og:url nor any link this page hands out - a reader who /// arrives from a post is a reader, not a participant. /// /// Not a permission. Anyone can add it, and all it unlocks is a button /// that composes a post the reader could write by hand; the page and the /// card behind it are public either way. pub participant: Option<String>,}
impl Ask { /// The query string that reproduces this ask, for a link or an og:url. fn query(&self) -> String { let mut parts: Vec<String> = Vec::new(); if let Some(perspective) = &self.perspective { parts.push(format!("perspective={}", urlencode(perspective))); } if let Some(card) = &self.card { parts.push(format!("card={}", urlencode(card))); } if parts.is_empty() { String::new() } else { format!("?{}", parts.join("&")) } }}
/// Which card a match should offer, given how it was reached and who was in/// it. The share menu lists these; the first is the one a bare link gets./// The cards this match can offer, best first.////// Only what it can actually draw: a duel needs two forces, a lance needs/// somebody's own, the challenge card needs a challenge, and the battlefield/// needs a board render. Nothing is listed that would come back as a worse/// version of the dossier.fn layouts( context: &Context, summary: &Summary, perspective: bool, board: bool,) -> Vec<card::Layout> { let mut out = Vec::new(); if matches!(context.entry, Entry::Daily { .. }) { out.push(card::Layout::Daily); } if summary.forces.len() == 2 { out.push(card::Layout::Duel); } out.push(card::Layout::Dossier); // One force in detail only makes sense as somebody's own force. if perspective { out.push(card::Layout::Lance); } if board { out.push(card::Layout::Battlefield); } out}
/// Build the picture from the result.////// The silhouette a machine gets is the one the page draws it with, and the/// camo is the force's own, read back out of the artifacts the match staged/// for arena. A force whose camo is missing is painted in MegaMek's grey,/// which is what an unpainted lance looks like in the game./// What the card is drawn from, beyond the result itself: the words already/// phrased for whoever asked, and the pictures the match left behind.struct Drawn<'a> { layout: card::Layout, /// What the scenario says about itself, for the card that draws it. brief: &'a Brief, /// "SCENARIO: FIRST RUN", built by the caller so it outlives the card. eyebrow: &'a str, /// VICTORY, DEFEAT or DRAW, when the card was asked for from a side. verdict: &'a str, title: &'a str, sub: &'a str, perspective: Option<&'a Perspective>, /// One entry per force, in the summary's order. camo: &'a [Option<Vec<u8>>], board: Option<Vec<u8>>, /// Design name to its own art, for the machines the unit index resolved. /// Fetched before the draw, because drawing is not async and should not /// become so to reach over the network mid-composition. art: &'a std::collections::HashMap<String, std::sync::Arc<Vec<u8>>>,}
/// Every picture on the card, fetched once each.////// Distinct by sprite rather than per machine: a lance of four Locusts is one/// request, and so is a platoon that all falls back to the same default. Art/// that would not come down is simply absent, and that machine is drawn/// without a picture - a card is always drawn.async fn unit_art( units: Option<&crate::units::UnitIndex>, summary: &Summary,) -> std::collections::HashMap<String, std::sync::Arc<Vec<u8>>> { let mut art = std::collections::HashMap::new(); let Some(index) = units else { return art; }; let mut fetched: std::collections::HashMap<String, Option<std::sync::Arc<Vec<u8>>>> = std::collections::HashMap::new(); for unit in summary.forces.iter().flat_map(|force| force.units.iter()) { let Some(name) = unit.get("name").and_then(serde_json::Value::as_str) else { continue; }; let Some(sprite) = picture_for(unit, units) else { continue; }; let bytes = match fetched.get(&sprite) { Some(bytes) => bytes.clone(), None => { let bytes = index.sprite(&sprite).await; fetched.insert(sprite, bytes.clone()); bytes } }; if let Some(bytes) = bytes { art.insert(name.to_owned(), bytes); } } art}
/// No art fetched, which is what every card assertion draws with: they are/// about composition and the silhouettes are the stable input for that. The/// fetch itself is `units::UnitIndex`'s to test.#[cfg(test)]fn no_art() -> std::collections::HashMap<String, std::sync::Arc<Vec<u8>>> { std::collections::HashMap::new()}
/// The same art a deployment would fetch, read off a local MegaMek install/// instead of the network, so the `cards` test can be looked at the way the/// card actually ships:////// CARD_UNITS=units.json CARD_MEGAMEK=~/.cache/mul-build/megamek \/// cargo test -p headquarters-api share::tests::cards -- --ignored////// `units.json` is `helm index`'s output - the same file infra publishes./// Neither variable set, this is empty and the card draws silhouettes.#[cfg(test)]fn local_art(summary: &Summary) -> std::collections::HashMap<String, std::sync::Arc<Vec<u8>>> { let mut art = std::collections::HashMap::new(); let (Ok(index), Ok(install)) = (std::env::var("CARD_UNITS"), std::env::var("CARD_MEGAMEK")) else { return art; }; let Ok(document) = std::fs::read(&index) else { return art; }; let Ok(document) = serde_json::from_slice::<serde_json::Value>(&document) else { return art; }; let base = document .get("sprite_base") .and_then(serde_json::Value::as_str) .unwrap_or("data/images/units"); let sprites: std::collections::HashMap<&str, &str> = document .get("units") .and_then(serde_json::Value::as_array) .map(|units| { units .iter() .filter_map(|unit| { Some((unit.get("name")?.as_str()?, unit.get("sprite")?.as_str()?)) }) .collect() }) .unwrap_or_default(); for unit in summary.forces.iter().flat_map(|force| force.units.iter()) { let Some(name) = unit.get("name").and_then(serde_json::Value::as_str) else { continue; }; // The same rule `picture_for` applies: the design's own art, and // MegaMek's default for its class where the index has no entry. let Some(sprite) = sprites .get(name) .map(|sprite| (*sprite).to_owned()) .or_else(|| sprite_for(unit).map(str::to_owned)) else { continue; }; if let Ok(bytes) = std::fs::read(format!("{install}/{base}/{sprite}")) { art.insert(name.to_owned(), std::sync::Arc::new(bytes)); } } art}
fn compose<'a>(summary: &'a Summary, drawn: Drawn<'a>) -> card::Card<'a> { let Drawn { layout, brief, eyebrow, verdict, title, sub, perspective, camo, board, art, } = drawn; let forces = summary .forces .iter() .enumerate() .map(|(index, force)| card::Force { name: &force.name, bot: force.bot, team: force.team, victor: summary.won(force), mine: perspective.map(|p| p.owns(force)).unwrap_or(false), units_remaining: force.units_remaining, units_start: force.units_start, // Clamped: a result reporting more battle value standing than // it started with is a bug upstream, and a bar drawn at 157% is // that bug repeated in public. bv_share: if force.bv_start > 0 { (force.bv_remaining * 100 / force.bv_start).clamp(0, 100) } else { 0 }, bv_remaining: force.bv_remaining, bv_start: force.bv_start, camo: camo.get(index).cloned().flatten(), zone: brief.zone_of(&force.slot), machines: force .units .iter() .map(|unit| { let name = unit .get("name") .and_then(serde_json::Value::as_str) .unwrap_or("Unknown machine"); card::Machine { name, // The design's own art where the index carries it, // MegaMek's default for its class where it does not, // and nothing at all when neither came down. sprite: art .get(name) .map(|bytes| std::borrow::Cow::Borrowed(bytes.as_slice())), condition: condition_word(unit), alive: !lost( unit.get("status") .and_then(serde_json::Value::as_str) .unwrap_or("active"), ), } }) .collect(), }) .collect(); card::Card { layout, verdict, blurb: &brief.blurb, eyebrow, title, sub, forces, board, }}
/// What a card leads with, and what it says underneath.////// Every layout leads with the verdict except the daily challenge, which/// leads with the challenge: its name is what another player recognises,/// having played the same one that day, and the verdict goes under it.fn headline( layout: card::Layout, context: &Context, verdict: &str, round: i64,) -> (String, String) { if layout == card::Layout::Daily && let Some(name) = &context.scenario { // The result plate carries the verdict, so the foot of the card is // the same quiet line every other card ends with. return (name.clone(), context.footer_line(round)); } (verdict.to_owned(), context.footer_line(round))}
/// A machine's condition as one short phrase, for a card that has room for a/// line and not a sentence. The page's own `condition` returns an owned/// String; a card borrows, so this is the static half of the same words.fn condition_word(unit: &serde_json::Value) -> &'static str { let status = unit .get("status") .and_then(serde_json::Value::as_str) .unwrap_or("active"); if status != "active" { return match status { "destroyed" => "destroyed", "devastated" => "devastated", "salvage" => "salvage", "retreated" => "withdrew", "ejected" => "ejected", "captured" => "captured", "pushed" => "pushed off", _ => "out of the fight", }; } match unit.get("damage").and_then(serde_json::Value::as_str) { Some("crippled") => "crippled", Some("heavy") => "heavy damage", Some("moderate") => "damaged", Some("light") => "light damage", _ => "unhurt", }}
/// The faces the page is set in: the site's own, so a report reads as part of/// the site rather than as a document the API happens to serve. See/// `assets/webfonts/README.md`.const FONTS: [(&str, &[u8]); 4] = [ ( "chakra-petch-600", include_bytes!("../assets/webfonts/chakra-petch-600-latin.woff2"), ), ( "chakra-petch-700", include_bytes!("../assets/webfonts/chakra-petch-700-latin.woff2"), ), ( "barlow-semi-condensed-400", include_bytes!("../assets/webfonts/barlow-semi-condensed-400-latin.woff2"), ), ( "barlow-semi-condensed-600", include_bytes!("../assets/webfonts/barlow-semi-condensed-600-latin.woff2"), ),];
/// One face. Immutable, so it is cached for a year.pub async fn font(Path(name): Path<String>) -> Response { let name = name.strip_suffix(".woff2").unwrap_or(&name); let Some((_, bytes)) = FONTS.iter().find(|(id, _)| *id == name) else { return StatusCode::NOT_FOUND.into_response(); }; ( [ (header::CONTENT_TYPE, "font/woff2"), (header::CACHE_CONTROL, "public, max-age=31536000, immutable"), ], *bytes, ) .into_response()}
/// Inline, because this page is one document and a stylesheet would be a/// second request an unfurler never makes. The palette is the site's.const STYLE: &str = "\/* The site's own faces and tokens, so a report reads as a page of lance.blue \ rather than as something the API serves. Kept in step by the tests named \ in assets/webfonts/README.md. */\@font-face{font-family:'Chakra Petch';font-style:normal;font-weight:600;\font-display:swap;src:url('/reports/fonts/chakra-petch-600.woff2') format('woff2')}\@font-face{font-family:'Chakra Petch';font-style:normal;font-weight:700;\font-display:swap;src:url('/reports/fonts/chakra-petch-700.woff2') format('woff2')}\@font-face{font-family:'Barlow Semi Condensed';font-style:normal;font-weight:400;\font-display:swap;src:url('/reports/fonts/barlow-semi-condensed-400.woff2') format('woff2')}\@font-face{font-family:'Barlow Semi Condensed';font-style:normal;font-weight:600;\font-display:swap;src:url('/reports/fonts/barlow-semi-condensed-600.woff2') format('woff2')}\/* light-dark() rather than a media query, for the site's own reason: a \ media query cannot see a reader who has told their browser otherwise, and \ a token defined in one branch and not the other keeps its dark value on a \ light page. */\:root{color-scheme:dark light;\--bg:light-dark(#f4f6fa,#0b1017);\--panel:light-dark(#ffffff,#131b26);\--line:light-dark(#d8dfea,#22303f);\--ink:light-dark(#15202b,#e6edf5);\--muted:light-dark(#5a6b80,var(--muted));\--accent:light-dark(#1f5fd0,#4c8dff);\--on-accent:light-dark(#ffffff,#0b1017);\--mark:light-dark(#0f7d92,#4fc1d6);\--font-display:'Chakra Petch',ui-sans-serif,system-ui,sans-serif;\--font-text:'Barlow Semi Condensed',ui-sans-serif,system-ui,sans-serif;\--danger:light-dark(#b3261e,#ff8a7a);\--warn:light-dark(#8a5a00,#d8a13a);\--sunk:light-dark(#eef1f7,#0f1621);\--good:light-dark(#0a7a52,#35d08a);\--faint:light-dark(#9aa7b8,#3d4a5a);\--hurt:light-dark(#c2410c,#ffb066);\--scar:light-dark(#e0b4ae,#5d3a38);\--radius:6px}\body{margin:0;background:var(--bg);color:var(--ink);font-family:var(--font-text);\font-size:1rem;line-height:1.5}\main{max-width:640px;margin:0 auto;padding:32px 20px 48px}\h1{margin:0;font-family:var(--font-display);font-weight:700;font-size:1.6rem;\line-height:1.25;letter-spacing:.01em}\.round{margin:6px 0 20px;color:var(--muted);font-size:.85rem}\.forces{list-style:none;margin:20px 0 0;padding:0;display:grid;gap:10px}\.forces li{padding:12px 14px;border:1px solid var(--line);border-radius:var(--radius);\background:var(--panel)}\.who{margin:0;font-family:var(--font-display);font-weight:600}\.who a{color:var(--ink);text-decoration:none}\.who a:hover{color:var(--accent);text-decoration:underline}\.victor{margin-left:6px;padding:1px 6px;border:1px solid var(--mark);border-radius:999px;\color:var(--mark);font-size:.7rem;text-transform:uppercase;letter-spacing:.1em}\.stats{margin:6px 0 8px;color:var(--muted);font-size:.8rem}\.bar{height:4px;border-radius:2px;background:var(--line);overflow:hidden}\.bar div{height:100%;background:var(--accent)}\.out{margin-top:24px}\.out a{color:var(--accent)}\.outsep{color:var(--muted)}\.eyebrow{margin:0 0 2px;font-family:var(--font-labels,var(--font-display));\font-size:.66rem;font-weight:700;letter-spacing:.16em;\text-transform:uppercase;color:var(--mark)}\.share{position:relative;margin:18px 0 0}\/* One slide at a time, chosen by the radio immediately before it. A radio \ rather than `:target`: following a fragment scrolls the gallery to the top \ of the viewport, so the whole page jumps on every press. The radios are \ rendered - kept out of sight rather than out of the box tree - so they \ stay focusable and the group can be turned with the arrow keys, and they \ are fixed so that focusing one can never scroll anything either. */\.pickr{position:fixed;top:0;left:0;width:1px;height:1px;margin:0;\opacity:0;pointer-events:none}\.slide{display:none}\.pickr:checked+.slide{display:block}\/* The labels cannot take focus, so the ring belongs to the radio holding it \ and is drawn around the card that radio is showing. */\.pickr:focus-visible+.slide .framed{outline:2px solid var(--mark);\outline-offset:3px}\.framed{position:relative}\.cardshot{display:block;width:100%;border:1px solid var(--line);border-radius:6px}\/* Full height of the card, because they are the control this block is for: \ a chevron floating in the middle of a busy picture is something to hunt \ for. The scrim is what makes them legible over any card. */\.turn{position:absolute;top:0;bottom:0;width:16%;min-width:3.6rem;\cursor:pointer;user-select:none;-webkit-user-select:none;\display:flex;align-items:center;justify-content:center;\color:var(--mark);font-size:2.2rem;line-height:1;text-decoration:none;\text-shadow:0 2px 12px rgba(0,0,0,.95)}\.turn.back{left:0;border-radius:6px 0 0 6px;\background:linear-gradient(90deg,rgba(6,10,15,.92),rgba(6,10,15,.05))}\.turn.on{right:0;border-radius:0 6px 6px 0;\background:linear-gradient(270deg,rgba(6,10,15,.92),rgba(6,10,15,.05))}\.turn:hover{color:var(--bg)}\/* The glyph is knocked out of a lit column on hover, so the control is \ unmistakably a control rather than a mark on the picture. */\.turn.back:hover{\background:linear-gradient(90deg,rgba(79,193,214,.94),rgba(79,193,214,.06))}\.turn.on:hover{\background:linear-gradient(270deg,rgba(79,193,214,.94),rgba(79,193,214,.06))}\.pick{display:flex;align-items:center;gap:8px;margin:8px 0 14px}\.named{font-size:.72rem;font-weight:700;letter-spacing:.14em;text-transform:uppercase;\color:var(--muted);margin-right:auto}\.dot{width:8px;height:8px;border-radius:50%;background:var(--line);display:block;\cursor:pointer}\.dot.here{background:var(--mark)}\.dot:hover{background:var(--accent)}\.sharerow{display:flex;justify-content:center}\/* Cut like a plate rather than rounded like a chip - the notch the site puts \ on its own controls, at the top left, so this reads as the same family of \ object as the Play button. */\.post{--notch:.6rem;display:inline-block;padding:.62rem 1.6rem;background:var(--accent);\color:var(--on-accent);font-size:.82rem;font-weight:700;letter-spacing:.14em;\text-transform:uppercase;text-decoration:none;\clip-path:polygon(var(--notch) 0,100% 0,100% 100%,0 100%,0 var(--notch))}\.post:hover{background:color-mix(in srgb,var(--accent) 82%,white)}\/* An outline is clipped away with the rest of the box, so the ring is inset. */\.post:focus-visible{outline:0;box-shadow:inset 0 0 0 2px var(--on-accent)}\.perma{flex:1;min-width:14rem;padding:.45rem .6rem;border:1px solid var(--line);\border-radius:4px;background:var(--sunk);color:var(--muted);font:inherit;font-size:.72rem}\.cta{display:flex;flex-wrap:wrap;align-items:baseline;gap:10px;margin:22px 0 0;\padding-top:16px;border-top:1px solid var(--line)}\.play{--notch:.6rem;display:inline-block;padding:.55rem 1.5rem;background:var(--mark);\color:var(--on-accent);font-size:.9rem;font-weight:700;letter-spacing:.14em;text-transform:uppercase;\text-decoration:none;\clip-path:polygon(var(--notch) 0,100% 0,100% 100%,0 100%,0 var(--notch))}\.play:hover{background:color-mix(in srgb,var(--mark) 82%,white)}\.play:focus-visible{outline:0;box-shadow:inset 0 0 0 2px var(--bg)}\.ctanote{font-size:.72rem;color:var(--muted)}\.force{display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));\gap:8px;margin-top:10px}\.utile{display:block;padding:8px;border:1px solid var(--line);border-radius:4px;\background:var(--sunk);color:var(--ink);text-decoration:none}\.utile:hover,.utile:focus{border-color:var(--accent);background:var(--panel)}\.utile.lost,.utile.crippled{border-color:var(--scar)}\.uicon{display:block;margin:0 auto 4px;width:84px;height:72px;image-rendering:pixelated}\.utile.lost .uicon{opacity:.45;filter:grayscale(1)}\.uname{display:block;font-size:.78rem;font-weight:600;overflow:hidden;\text-overflow:ellipsis;white-space:nowrap}\.ufig{display:block;font-size:.66rem;color:var(--muted)}\.ustate{display:block;margin-top:2px;font-size:.62rem;text-transform:uppercase;\letter-spacing:.08em;color:var(--muted)}\.utile.whole .ustate{color:var(--good)}\.utile.light .ustate,.utile.moderate .ustate{color:var(--hurt)}\.utile.heavy .ustate{color:var(--warn)}\.utile.crippled .ustate,.utile.lost .ustate{color:var(--danger)}\.ubar{display:block;height:3px;margin-top:5px;border-radius:2px;background:var(--line)}\.ubar span{display:block;height:100%;border-radius:2px;background:var(--mark)}\.modal{display:none}\.modal:target{display:block;position:fixed;inset:0;z-index:20;overflow-y:auto;\padding:24px 12px;background:rgba(4,8,13,.82)}\.mbox{position:relative;z-index:2;max-width:820px;margin:0 auto;padding:16px;\border:1px solid var(--line);border-radius:6px;background:var(--panel)}\.mback{position:fixed;inset:0;z-index:1;display:block}\.mbox h2{margin:0;font-size:1.05rem}\.mbox h3{margin:14px 0 6px;font-size:.7rem;text-transform:uppercase;\letter-spacing:.1em;color:var(--muted)}\.mfacts{margin:4px 0 0;font-size:.75rem;color:var(--muted)}\.mclose{position:absolute;top:8px;right:12px;color:var(--muted);font-size:1.3rem;\line-height:1;text-decoration:none}\.mclose:hover{color:var(--ink)}\.sheet{display:grid;gap:5px;margin-top:10px;align-items:start}\.sheet.mek{grid-template-columns:repeat(5,1fr);\grid-template-areas:\". . hd . .\" \"la lt ct rt ra\" \". ll . rl .\"}\.sheet.veh{grid-template-columns:repeat(3,1fr);max-width:430px;margin-inline:auto;\grid-template-areas:\". fr .\" \"ls bd rs\" \". tu .\" \". ft .\" \". rr .\"}\.loc{padding:5px;border:1px solid var(--line);border-radius:3px;background:var(--sunk)}\.loc.gone{border-color:var(--scar)}\.loc.off{border-style:dashed;border-color:var(--danger)}\.labbr{margin:0 0 3px;font-size:.62rem;font-weight:700;letter-spacing:.08em}\.lstate{margin-left:5px;font-weight:400;color:var(--danger)}\.meter{display:flex;align-items:center;gap:4px;margin-top:3px;font-size:.6rem;\line-height:1.4;color:var(--muted)}\.mlabel{width:.8em;flex:none}\.mtrack{flex:1;height:3px;border-radius:2px;background:var(--line);overflow:hidden}\.mtrack span{display:block;height:100%;background:var(--mark)}\.structure .mtrack span{background:var(--muted)}\.structure.hurt .mtrack span{background:var(--danger)}\.mfig{flex:none;font-variant-numeric:tabular-nums}\.slots{display:flex;flex-wrap:wrap;gap:2px;margin-top:4px}\.s{width:6px;height:6px;border-radius:1px;background:var(--muted)}\.s.empty{background:transparent;box-shadow:inset 0 0 0 1px var(--line)}\.s.hit,.s.breached{background:var(--warn)}\.s.destroyed{background:var(--danger)}\.s.missing{background:transparent;box-shadow:inset 0 0 0 1px var(--danger)}\.tally{margin:3px 0 0;font-size:.58rem;color:var(--muted);font-variant-numeric:tabular-nums}\.crit{margin:3px 0 0;font-size:.58rem;line-height:1.35;color:var(--danger)}\.sheet.flow{grid-template-columns:repeat(auto-fill,minmax(120px,1fr));grid-template-areas:none}\.sheet.flow .loc{grid-area:auto}\.note{margin:10px 0 0;padding:7px 9px;border-left:3px solid var(--warn);background:var(--sunk);\font-size:.72rem;color:var(--muted)}\.critlist{list-style:none;margin:4px 0 0;padding:0;border-top:1px solid var(--line)}\.critlist li{display:flex;gap:4px;padding:0 1px;font-size:.52rem;line-height:1.45;\white-space:nowrap;overflow:hidden;text-overflow:ellipsis}\.critlist .n{width:1.1em;flex:none;text-align:right;color:var(--faint);\font-variant-numeric:tabular-nums}\.critlist .empty{color:var(--faint)}\.critlist .hit,.critlist .breached{color:var(--warn)}\.critlist .destroyed,.critlist .missing{color:var(--danger);text-decoration:line-through}\.equip{list-style:none;margin:0;padding:0;border:1px solid var(--line);border-radius:3px;\background:var(--sunk);overflow:hidden}\.equip li{display:flex;align-items:baseline;gap:8px;padding:2px 8px;font-size:.68rem;\border-bottom:1px solid rgba(34,48,63,.6)}\.equip li:last-child{border-bottom:0}\.equip .ename{flex:1}\.equip .eloc{width:2.4em;color:var(--muted);font-size:.62rem;text-transform:uppercase;\letter-spacing:.06em}\.equip .eshots{color:var(--muted);font-variant-numeric:tabular-nums}\.equip .ecount{color:var(--muted)}\.equip .hit{color:var(--warn)}\.equip .destroyed{color:var(--danger);text-decoration:line-through}\.vdamage{list-style:none;margin:0;padding:0;display:grid;gap:2px;font-size:.68rem}\.vdamage .ok{color:var(--muted)}\.vdamage .destroyed{color:var(--danger)}\.troopers{margin:0 0 4px;font-size:.72rem;color:var(--muted)}\.ammo{list-style:none;margin:0;padding:0;display:grid;gap:3px}\.ammo li{display:flex;align-items:baseline;gap:6px;font-size:.68rem;color:var(--muted)}\.aname{flex:1;color:var(--ink)}\.ashots{font-variant-numeric:tabular-nums}\.gone{color:var(--danger)}\@media (max-width:560px){.sheet{grid-template-columns:repeat(2,1fr);grid-template-areas:none}\.loc{grid-area:auto}}\";
#[cfg(test)]mod tests {
/// A side that lost every machine is flagged `observer` by MegaMek, and /// arena records each seat as the game last described it. Dropping it took /// the loser out of a decided match, which then read as a draw with one /// force in it - match 06e4fd45, where the OPFOR seat held three Meks and /// 3,142 battle value and did not appear on the report at all. #[test] fn a_wiped_out_side_is_the_loser_not_a_watcher() { let result = serde_json::json!({ "round": 5, "victoryTeam": 1, "victoryPlayerId": -1, "players": [ {"id": 0, "name": "@permadeath.com", "slot": "Challenger", "observer": false, "team": 1, "unitsStart": 1, "unitsRemaining": 1, "bvStart": 3132, "bvRemaining": 2766, "units": []}, {"id": 1, "name": "@lance.blue", "slot": "OPFOR", "observer": true, "team": 2, "unitsStart": 3, "unitsRemaining": 0, "bvStart": 3142, "bvRemaining": 0, "units": []}, {"id": 3, "name": "ArenaObserver", "slot": "ArenaObserver", "observer": true, "team": -1, "unitsStart": 0, "unitsRemaining": 0, "bvStart": 0, "bvRemaining": 0, "units": []} ] });
let summary = summarise(&result).expect("a summary"); let names: Vec<&str> = summary.forces.iter().map(|f| f.name.as_str()).collect(); assert_eq!( names, vec!["@permadeath.com", "@lance.blue"], "the watcher goes, both fighters stay" ); assert_eq!(summary.title(), "@permadeath.com beat @lance.blue"); } use super::*;
/// One real machine, as arena's UnitReport wrote it: a Griffin with its /// centre torso stripped to structure, its left arm blown off, its engine /// hit and a wounded pilot. Captured from the container rather than /// invented, so a change in the document's shape shows up here. fn damaged_griffin() -> serde_json::Value { serde_json::from_str(GRIFFIN).expect("the captured unit parses") }
const GRIFFIN: &str = r#"{"id": 0, "name": "Griffin GRF-1N", "chassis": "Griffin", "model": "GRF-1N", "kind": "mek", "tons": 55.0, "bv": 1755, "status": "active", "armorPercent": 78, "damage": "heavy", "crippled": false, "heat": 0, "heatCapacity": 12, "crew": {"name": "Trainee A2", "gunnery": 4, "piloting": 0, "hits": 2, "dead": false, "ejected": false}, "ammo": [{"name": "LRM 10 Ammo", "loc": "RT", "shots": 12, "shotsMax": 12, "destroyed": false}, {"name": "LRM 10 Ammo", "loc": "RT", "shots": 12, "shotsMax": 12, "destroyed": false}], "equipment": [{"name": "PPC", "loc": "RA", "hit": false, "destroyed": false}, {"name": "Jump Jet", "loc": "LT", "hit": false, "destroyed": false}, {"name": "Jump Jet", "loc": "LT", "hit": false, "destroyed": false}, {"name": "Jump Jet", "loc": "RT", "hit": false, "destroyed": false}, {"name": "Jump Jet", "loc": "RT", "hit": false, "destroyed": false}, {"name": "LRM 10", "loc": "RT", "hit": false, "destroyed": false}, {"name": "LRM 10 Ammo", "loc": "RT", "hit": false, "destroyed": false, "shots": 12, "shotsMax": 12}, {"name": "LRM 10 Ammo", "loc": "RT", "hit": false, "destroyed": false, "shots": 12, "shotsMax": 12}, {"name": "Heat Sink", "loc": "CT", "hit": false, "destroyed": false}, {"name": "Jump Jet", "loc": "CT", "hit": false, "destroyed": false}, {"name": "Heat Sink", "loc": "", "hit": false, "destroyed": false}, {"name": "Heat Sink", "loc": "", "hit": false, "destroyed": false}, {"name": "Heat Sink", "loc": "", "hit": false, "destroyed": false}, {"name": "Heat Sink", "loc": "", "hit": false, "destroyed": false}, {"name": "Heat Sink", "loc": "", "hit": false, "destroyed": false}, {"name": "Heat Sink", "loc": "", "hit": false, "destroyed": false}, {"name": "Heat Sink", "loc": "", "hit": false, "destroyed": false}, {"name": "Heat Sink", "loc": "", "hit": false, "destroyed": false}, {"name": "Heat Sink", "loc": "", "hit": false, "destroyed": false}, {"name": "Heat Sink", "loc": "", "hit": false, "destroyed": false}, {"name": "Heat Sink", "loc": "", "hit": false, "destroyed": false}], "vehicle": null, "infantry": null, "locations": [{"abbr": "HD", "name": "Head", "armor": 9, "armorMax": 9, "internal": 3, "internalMax": 3, "destroyed": false, "blownOff": false, "slots": [{"name": "Life Support", "hit": false, "destroyed": false, "breached": false, "missing": false}, {"name": "Sensors", "hit": false, "destroyed": false, "breached": false, "missing": false}, {"name": "Standard Cockpit", "hit": false, "destroyed": false, "breached": false, "missing": false}, null, {"name": "Sensors", "hit": false, "destroyed": false, "breached": false, "missing": false}, {"name": "Life Support", "hit": false, "destroyed": false, "breached": false, "missing": false}]}, {"abbr": "CT", "name": "Center Torso", "armor": 0, "armorMax": 20, "internal": 3, "internalMax": 18, "destroyed": false, "blownOff": false, "rear": 7, "rearMax": 7, "slots": [{"name": "Engine", "hit": true, "destroyed": true, "breached": false, "missing": false}, {"name": "Engine", "hit": false, "destroyed": false, "breached": false, "missing": false}, {"name": "Engine", "hit": false, "destroyed": false, "breached": false, "missing": false}, {"name": "Standard Gyro", "hit": false, "destroyed": false, "breached": false, "missing": false}, {"name": "Standard Gyro", "hit": false, "destroyed": false, "breached": false, "missing": false}, {"name": "Standard Gyro", "hit": false, "destroyed": false, "breached": false, "missing": false}, {"name": "Standard Gyro", "hit": false, "destroyed": false, "breached": false, "missing": false}, {"name": "Engine", "hit": false, "destroyed": false, "breached": false, "missing": false}, {"name": "Engine", "hit": false, "destroyed": false, "breached": false, "missing": false}, {"name": "Engine", "hit": false, "destroyed": false, "breached": false, "missing": false}, {"name": "Heat Sink", "hit": false, "destroyed": false, "breached": false, "missing": false}, {"name": "Jump Jet", "hit": false, "destroyed": false, "breached": false, "missing": false}]}, {"abbr": "RT", "name": "Right Torso", "armor": 20, "armorMax": 20, "internal": 13, "internalMax": 13, "destroyed": false, "blownOff": false, "rear": 6, "rearMax": 6, "slots": [{"name": "Jump Jet", "hit": false, "destroyed": false, "breached": false, "missing": false}, {"name": "Jump Jet", "hit": false, "destroyed": false, "breached": false, "missing": false}, {"name": "LRM 10", "hit": false, "destroyed": false, "breached": false, "missing": false}, {"name": "LRM 10", "hit": false, "destroyed": false, "breached": false, "missing": false}, {"name": "LRM 10 Ammo", "hit": false, "destroyed": false, "breached": false, "missing": false}, {"name": "LRM 10 Ammo", "hit": false, "destroyed": false, "breached": false, "missing": false}, null, null, null, null, null, null]}, {"abbr": "LT", "name": "Left Torso", "armor": 20, "armorMax": 20, "internal": 13, "internalMax": 13, "destroyed": false, "blownOff": false, "rear": 6, "rearMax": 6, "slots": [{"name": "Jump Jet", "hit": false, "destroyed": false, "breached": false, "missing": false}, {"name": "Jump Jet", "hit": false, "destroyed": false, "breached": false, "missing": false}, null, null, null, null, null, null, null, null, null, null]}, {"abbr": "RA", "name": "Right Arm", "armor": 14, "armorMax": 14, "internal": 9, "internalMax": 9, "destroyed": false, "blownOff": false, "slots": [{"name": "Shoulder", "hit": false, "destroyed": false, "breached": false, "missing": false}, {"name": "Upper Arm", "hit": false, "destroyed": false, "breached": false, "missing": false}, {"name": "Lower Arm", "hit": false, "destroyed": false, "breached": false, "missing": false}, {"name": "Hand", "hit": false, "destroyed": false, "breached": false, "missing": false}, {"name": "PPC", "hit": false, "destroyed": false, "breached": false, "missing": false}, {"name": "PPC", "hit": false, "destroyed": false, "breached": false, "missing": false}, {"name": "PPC", "hit": false, "destroyed": false, "breached": false, "missing": false}, null, null, null, null, null]}, {"abbr": "LA", "name": "Left Arm", "armor": 0, "armorMax": 14, "internal": 0, "internalMax": 9, "destroyed": true, "blownOff": true, "slots": [{"name": "Shoulder", "hit": false, "destroyed": false, "breached": false, "missing": false}, {"name": "Upper Arm", "hit": false, "destroyed": false, "breached": false, "missing": false}, {"name": "Lower Arm", "hit": false, "destroyed": false, "breached": false, "missing": false}, {"name": "Hand", "hit": false, "destroyed": false, "breached": false, "missing": false}, null, null, null, null, null, null, null, null]}, {"abbr": "RL", "name": "Right Leg", "armor": 18, "armorMax": 18, "internal": 13, "internalMax": 13, "destroyed": false, "blownOff": false, "slots": [{"name": "Hip", "hit": false, "destroyed": false, "breached": false, "missing": false}, {"name": "Upper Leg", "hit": false, "destroyed": false, "breached": false, "missing": false}, {"name": "Lower Leg", "hit": false, "destroyed": false, "breached": false, "missing": false}, {"name": "Foot", "hit": false, "destroyed": false, "breached": false, "missing": false}, null, null]}, {"abbr": "LL", "name": "Left Leg", "armor": 18, "armorMax": 18, "internal": 13, "internalMax": 13, "destroyed": false, "blownOff": false, "slots": [{"name": "Hip", "hit": false, "destroyed": false, "breached": false, "missing": false}, {"name": "Upper Leg", "hit": false, "destroyed": false, "breached": false, "missing": false}, {"name": "Lower Leg", "hit": false, "destroyed": false, "breached": false, "missing": false}, {"name": "Foot", "hit": false, "destroyed": false, "breached": false, "missing": false}, null, null]}]}"#;
/// A real vehicle, captured the same way: five locations, twenty-five /// slots each with four of them filled, and MegaMek's own vehicle /// criticals. It is the case a Mek-shaped sheet gets wrong. fn packrat() -> serde_json::Value { serde_json::from_str(PACKRAT).expect("the captured vehicle parses") }
const PACKRAT: &str = r#"{"id": 11, "name": "Packrat LRPV PKR-T5", "chassis": "Packrat LRPV", "model": "PKR-T5", "kind": "vehicle", "tons": 20.0, "bv": 248, "status": "active", "armorPercent": 100, "damage": "none", "crippled": false, "heat": 0, "heatCapacity": 999, "crew": {"name": "Ernest (Patch) McGuire", "gunnery": 7, "piloting": 7, "hits": 0, "dead": false, "ejected": false}, "ammo": [{"name": "SRM 6 Ammo", "loc": "BD", "shots": 15, "shotsMax": 15, "destroyed": false}, {"name": "SRM 6 Ammo", "loc": "BD", "shots": 15, "shotsMax": 15, "destroyed": false}], "equipment": [{"name": "SRM 6", "loc": "FR", "hit": false, "destroyed": false}, {"name": "Flamer", "loc": "RR", "hit": false, "destroyed": false}, {"name": "SRM 6 Ammo", "loc": "BD", "hit": false, "destroyed": false, "shots": 15, "shotsMax": 15}, {"name": "SRM 6 Ammo", "loc": "BD", "hit": false, "destroyed": false, "shots": 15, "shotsMax": 15}], "vehicle": {"turretLocked": false, "motiveDamage": 0, "driverHit": false, "commanderHit": false, "stabilisers": []}, "infantry": null, "locations": [{"abbr": "BD", "name": "Body", "armor": 0, "armorMax": 0, "internal": 0, "internalMax": 0, "destroyed": false, "blownOff": false, "slots": [{"name": "SRM 6 Ammo", "hit": false, "destroyed": false, "breached": false, "missing": false}, {"name": "SRM 6 Ammo", "hit": false, "destroyed": false, "breached": false, "missing": false}, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null]}, {"abbr": "FR", "name": "Front", "armor": 16, "armorMax": 16, "internal": 2, "internalMax": 2, "destroyed": false, "blownOff": false, "slots": [{"name": "SRM 6", "hit": false, "destroyed": false, "breached": false, "missing": false}, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null]}, {"abbr": "RS", "name": "Right", "armor": 16, "armorMax": 16, "internal": 2, "internalMax": 2, "destroyed": false, "blownOff": false, "slots": [null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null]}, {"abbr": "LS", "name": "Left", "armor": 16, "armorMax": 16, "internal": 2, "internalMax": 2, "destroyed": false, "blownOff": false, "slots": [null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null]}, {"abbr": "RR", "name": "Rear", "armor": 16, "armorMax": 16, "internal": 2, "internalMax": 2, "destroyed": false, "blownOff": false, "slots": [{"name": "Flamer", "hit": false, "destroyed": false, "breached": false, "missing": false}, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null]}]}"#;
/// A match reached the way most are: a scenario against Princess, played /// on a day. The daily and duel headings are the same code with a /// different row, and `entry_reads_the_row` covers those. fn fixture_context() -> Context { Context::read( &crate::db::MatchRow { id: "m1".into(), owner_did: "did:plc:a".into(), status: "over".into(), token: "t".into(), task_arn: None, task_ip: None, // 2026-08-19, which is what the page should print. created_at: 1_787_097_600, scenario: Some("data/scenarios/TrainingScenarios/1-FirstRun.mms".into()), // Eleven minutes from playable to decided. started_at: Some(1_787_097_600), ended_at: Some(1_787_098_260), }, &[], ) }
/// The page as a bare link gets it: no point of view, the first card. fn rendered(summary: &Summary) -> String { let context = fixture_context(); let offered = layouts(&context, summary, false, false); document( summary, &context, &Page { here: "https://lance.blue/reports/m1", base: "https://lance.blue/reports/m1", image: "https://lance.blue/reports/m1/card.png", home: "https://lance.blue", perspective: None, offered: &offered, chosen: offered[0], participant: true, units: None, }, ) }
/// The same match, reached as the day's challenge. fn daily_context() -> Context { Context::read( &crate::db::MatchRow { id: "m1".into(), owner_did: "did:plc:a".into(), status: "over".into(), token: "t".into(), task_arn: None, task_ip: None, created_at: 1_787_097_600, scenario: Some("daily:bug-company".into()), // Eleven minutes from playable to decided. started_at: Some(1_787_097_600), ended_at: Some(1_787_098_260), }, &[], ) }
/// A machine the index knows is drawn as itself; one it does not gets /// MegaMek's own default for its class, addressed the same way. Both /// halves matter: the index is Meks only, so every vehicle and platoon /// depends on the fallback, and neither half is a file in this repo. #[test] fn a_known_design_is_drawn_as_itself() { let index = crate::units::for_test(&[("Thunderbolt TDR-5S", "meks/thunderbolt.png")]); let thunderbolt = serde_json::json!({ "name": "Thunderbolt TDR-5S", "kind": "mek", "tons": 65.0, "status": "active" }); let tile = unit_tile("u0-0", &thunderbolt, Some(&index)); assert!( tile.contains( "https://lance.blue/assets/megamek/0.51.0/data/images/units/meks/thunderbolt.png" ), "the design's own art: {tile}" );
let demolisher = serde_json::json!({ "name": "Demolisher Heavy Tank", "kind": "vehicle", "tons": 80.0, "status": "active" }); let tile = unit_tile("u0-1", &demolisher, Some(&index)); assert!( tile.contains( "https://lance.blue/assets/megamek/0.51.0/data/images/units/\ defaults/default_tracked.png" .replace(['\n', ' '], "") .as_str() ), "MegaMek's default for a ground vehicle: {tile}" ); // Nothing under /reports/ any more: the pictures are the release's. assert!(!tile.contains("/reports/sprites/"), "{tile}"); }
/// Two 65-tonne bipeds are one silhouette and two pictures. This is the /// whole point of reading the index: without it a Thunderbolt and a /// Crusader are the same image on the same card. #[test] fn two_designs_of_a_weight_are_two_pictures() { let index = crate::units::for_test(&[ ("Thunderbolt TDR-5S", "meks/thunderbolt.png"), ("Crusader CRD-3R", "meks/Crusader.png"), ]); let mek = |name: &str| serde_json::json!({"name": name, "kind": "mek", "tons": 65.0, "status": "active"}); assert_eq!( sprite_for(&mek("Thunderbolt TDR-5S")), sprite_for(&mek("Crusader CRD-3R")), "the silhouettes were already distinct; this test proves nothing" ); assert_ne!( unit_tile("u0-0", &mek("Thunderbolt TDR-5S"), Some(&index)), unit_tile("u0-0", &mek("Crusader CRD-3R"), Some(&index)), ); }
/// Whatever was fetched for a machine is what the card paints, and a /// machine nothing came down for is painted with nothing rather than /// with a stand-in. Checked on this side of the split because the page /// and the card reach the same picture by completely different routes - /// an `<img>` address, and a fetch. #[test] fn the_card_paints_what_was_fetched() { let mut doc = result(); doc["players"][0]["units"] = serde_json::json!([damaged_griffin()]); let summary = summarise(&doc).expect("a summary"); let name = summary.forces[0].units[0]["name"] .as_str() .expect("the machine is named") .to_owned();
let mut art = std::collections::HashMap::new(); art.insert( name.clone(), std::sync::Arc::new(crate::card::flat_camo([10, 200, 90])), ); let camo: Vec<Option<Vec<u8>>> = summary.forces.iter().map(|_| None).collect(); let machine = |art: &std::collections::HashMap<String, std::sync::Arc<Vec<u8>>>| { compose( &summary, Drawn { layout: card::Layout::Dossier, brief: &Brief::default(), eyebrow: "SCENARIO: FIRST RUN", verdict: "VICTORY", title: "@a.example won", sub: "First Run", perspective: None, camo: &camo, board: None, art, }, ) .forces[0] .machines[0] .sprite .as_ref() .map(|bytes| bytes.to_vec()) }; assert_eq!( machine(&art).as_deref(), Some(crate::card::flat_camo([10, 200, 90]).as_slice()), "the fetched art did not reach the card" ); assert_eq!( machine(&no_art()), None, "a machine nothing came down for was painted with something" ); }
/// No index at all - development, or a fetch that failed - draws the /// machine without a picture rather than with a broken one. /// /// The release the art is published under is the index's own, so with no /// index there is no address to build. That is the cost of holding no /// copies here, and it is why `units::load` says so at startup: the tile /// still carries the name, the tonnage and the condition, which is what /// a reader is here for. #[test] fn no_index_draws_no_picture() { let thunderbolt = serde_json::json!({ "name": "Thunderbolt TDR-5S", "kind": "mek", "tons": 65.0, "status": "active" }); let tile = unit_tile("u0-0", &thunderbolt, None); assert!( !tile.contains("<img"), "a picture with no release to draw it from: {tile}" ); assert!(tile.contains("Thunderbolt TDR-5S"), "{tile}"); assert!(tile.contains("65 t"), "{tile}"); }
fn result() -> serde_json::Value { serde_json::json!({ "round": 7, "victoryTeam": 1, "victoryPlayerId": -1, "players": [ {"id": 1, "name": "@a.example", "slot": "Trainee", "observer": false, "team": 1, "unitsRemaining": 1, "unitsStart": 1, "bvRemaining": 1639, "bvStart": 1842}, {"id": 2, "name": "@lance.blue", "slot": "OPFOR", "observer": false, "team": 2, "unitsRemaining": 0, "unitsStart": 1, "bvRemaining": 0, "bvStart": 1817}, {"id": 3, "name": "ArenaObserver", "slot": "ArenaObserver", "observer": false, "team": 0, "unitsRemaining": 0, "unitsStart": 0, "bvRemaining": 0, "bvStart": 0} ], "identity": {"players": [ {"slot": "Trainee", "control": "human", "did": "did:plc:abc", "handle": "a.example"}, {"slot": "OPFOR", "control": "bot", "did": null, "handle": "lance.blue"} ]} }) }
/// The watcher is in every result document and is not a player. It reached /// the match report once; nothing that renders a result may show it. #[test] fn the_watcher_is_not_a_force() { let summary = summarise(&result()).expect("a summary"); assert_eq!(summary.forces.len(), 2); assert!(summary.forces.iter().all(|f| f.name != "ArenaObserver")); }
/// Recorded, never inferred: the loser here still has units on the board /// in a forced victory, so anything counting them would name them winner. #[test] fn the_winner_comes_from_the_document() { let summary = summarise(&result()).expect("a summary"); assert_eq!(summary.title(), "@a.example beat @lance.blue"); }
#[test] fn a_draw_says_so() { let mut doc = result(); doc["victoryTeam"] = 0.into(); let summary = summarise(&doc).expect("a summary"); assert_eq!(summary.title(), "@a.example vs @lance.blue - a drawn match"); }
/// The opponent is named and never linked. A public page is not a place /// to put somebody else's account in front of an audience they did not /// choose - see the consent rule in plan/post-to-bluesky.md. #[test] fn no_handle_is_a_link() { let html = page(); assert!(html.contains("@lance.blue")); assert!(!html.contains("bsky.app/profile")); }
/// The report is where a player lands when a match ends, and the survey /// link at the foot of it is the one this alpha asks from. It is a /// constant three bundles carry a copy of, so a typo here is a dead link /// nothing else would catch. #[test] fn the_report_links_the_survey() { let html = page(); assert!(html.contains(SURVEY_URL)); assert!(html.contains(SURVEY_LABEL)); }
/// Handles are chosen by whoever holds them and arrive in a document /// written inside a match container. Nothing interpolated into the page /// may close a tag. #[test] fn names_are_escaped() { assert_eq!( escape("<script>alert(\"x\")</script>"), "<script>alert("x")</script>" ); }
fn page() -> String { let mut doc = result(); // The preview and the tag assertions read better against a page with // machines on it, which is what a finished match now produces. Four // of them, in four states, because the force card is a grid and a // grid of one says nothing about the grid. let mut wreck = damaged_griffin(); wreck["name"] = "Locust LCT-1V".into(); wreck["tons"] = 20.0.into(); wreck["status"] = "destroyed".into(); wreck["damage"] = "crippled".into(); wreck["armorPercent"] = 0.into(); let mut fresh = damaged_griffin(); fresh["name"] = "Atlas AS7-D".into(); fresh["tons"] = 100.0.into(); fresh["damage"] = "none".into(); fresh["armorPercent"] = 100.into(); doc["players"][0]["units"] = serde_json::json!([damaged_griffin(), fresh, packrat(), wreck]); let summary = summarise(&doc).expect("a summary"); rendered(&summary) }
/// A vehicle is not a Mek, and its sheet says so: the armour diagram it /// does have, MegaMek's own vehicle criticals, and its equipment. No /// column of twenty-five numbered slots per location, four of which hold /// anything. #[test] fn a_vehicle_gets_the_vehicle_sheet() { let mut doc = result(); doc["players"][0]["units"] = serde_json::json!([packrat()]); let summary = summarise(&doc).expect("a summary"); let html = rendered(&summary); assert!( html.contains("Vehicle damage"), "the vehicle's own criticals" ); assert!(html.contains("Weapons and equipment"), "what it carried"); assert!(!html.contains("Critical hits"), "no Mek crit columns"); assert!(!html.contains("Roll again"), "no empty Mek slots"); }
/// A class nobody has drawn a sheet for says so, rather than being poured /// into a Mek's body plan. #[test] fn an_unknown_class_says_it_has_no_sheet() { let mut unit = packrat(); unit["kind"] = "aerospace".into(); let mut doc = result(); doc["players"][0]["units"] = serde_json::json!([unit]); let summary = summarise(&doc).expect("a summary"); let html = rendered(&summary); assert!(html.contains("No record sheet for this class")); assert!(html.contains("Weapons and equipment")); }
/// The machines, and the damage on them. A page that lists a force's /// units without saying what happened to them is the summary again. #[test] fn the_page_draws_the_record_sheet() { let mut doc = result(); doc["players"][0]["units"] = serde_json::json!([damaged_griffin()]); let summary = summarise(&doc).expect("a summary"); let html = rendered(&summary); assert!(html.contains("Griffin GRF-1N")); // The stripped torso, the blown-off arm and the engine, each read // back out of the page rather than out of the document. assert!(html.contains("0/20"), "the stripped torso"); assert!(html.contains("blown off"), "the arm"); // The classic sheet: every slot numbered, in the box of the location // it is in, with the empties saying what the paper sheet says. assert!( html.contains("<ol class=\"critlist\">"), "slots in their boxes" ); assert!(html.contains("Roll again"), "empty slots keep their line"); assert!(!html.contains("of 12 slots"), "no tally beside the armour"); assert!( html.contains("class=\"destroyed\"><span class=\"n\">"), "a struck slot" ); assert!( html.contains("Weapons and equipment"), "everything it carries" ); assert!(html.contains("2 hits"), "the pilot"); // MegaMek's own reading of the machine, which is the one thing here a // reader does not have to know record sheets to use. assert!( html.contains("heavy damage") || html.contains("crippled") || html.contains("damaged") || html.contains("unhurt"), "the condition" ); // The force card comes first, and the sheet is behind a click that // needs no script. assert!(html.contains("class=\"utile"), "the force card tile"); // No index in this test, so no release to address a picture under - // the tile is the machine's name and its condition, and nothing is // served from this repo for it any more. assert!( !html.contains("/reports/sprites/"), "a silhouette this service ships" ); assert!( html.contains("class=\"modal\" id=\"u0-0\""), "the sheet's panel" ); assert!(html.contains("href=\"#u0-0\""), "the way into it"); }
/// A location's box carries its own slots, numbered, with the empties in /// place: which line went is what a record sheet is for, and the tally /// this replaced could only say how many. #[test] fn a_location_box_carries_its_slots() { let unit = damaged_griffin(); let torso = unit["locations"] .as_array() .expect("locations") .iter() .find(|location| location["abbr"] == "CT") .expect("a centre torso"); let column = crit_column(torso); assert!(column.contains("<ol class=\"critlist\">")); // Twelve lines, whatever is in them. assert_eq!(column.matches("<li").count(), 12); assert!( column.contains("class=\"destroyed\""), "the engine that went" ); }
/// How the match was reached is read off the row and its seats, because /// the result document says nothing about it. A daily is labelled by the /// row; a duel is two people rather than a flag. #[test] fn entry_reads_the_row() { let row = |scenario: &str| crate::db::MatchRow { id: "m1".into(), owner_did: "did:plc:a".into(), status: "over".into(), token: "t".into(), task_arn: None, task_ip: None, created_at: 1_787_097_600, scenario: Some(scenario.to_owned()), started_at: None, ended_at: None, }; let human = |did: &str| crate::db::MatchPlayer { slot: did.to_owned(), control: "human".into(), did: Some(did.to_owned()), handle: None, team: None, }; let bot = crate::db::MatchPlayer { slot: "OPFOR".into(), control: "bot".into(), did: None, handle: None, team: None, };
let daily = Context::read( &row("daily:bug-company"), &[human("did:plc:a"), bot.clone()], ); assert_eq!(daily.entry.eyebrow(), "Daily challenge"); // The slug is a file name; the page says what it is called. assert_eq!(daily.scenario.as_deref(), Some("Bug Company")); assert_eq!( scenario_name("data/scenarios/TrainingScenarios/1-FirstRun.mms").as_deref(), Some("First Run"), );
let duel = Context::read( &row("data/scenarios/Duels/Monte Diablo.mms"), &[human("did:plc:a"), human("did:plc:b")], ); assert_eq!(duel.entry.eyebrow(), "Duel"); assert_eq!(duel.scenario.as_deref(), Some("Monte Diablo"));
let solo = Context::read(&row("data/scenarios/Training/1-FirstRun.mms"), &[bot]); assert_eq!(solo.entry.eyebrow(), "Scenario"); assert_eq!(solo.played.as_deref(), Some("2026-08-19")); }
/// The page a reader lands on has to be shareable from, or the feature /// only exists in the seconds before the match container dies. #[test] fn the_page_can_be_shared_from() { let html = page(); assert!(html.contains("bsky.app/intent/compose"), "a composer"); // And somewhere to go for whoever arrived from the post. assert!(html.contains("class=\"play\""), "a way in"); }
/// The post is the link and nothing else: what a sentence here would say /// is drawn on the card the link unfurls into. #[test] fn the_post_is_only_the_link() { let composed = compose_url("https://lance.blue/reports/m1?perspective=%40a.example"); assert_eq!( composed, "https://bsky.app/intent/compose?text=https%3A%2F%2Flance.blue%2Freports%2Fm1%3Fperspective%3D%2540a.example" ); // Nothing of the verdict, the scenario or the round. for said in ["beat", "drawn", "round", "Scenario"] { assert!(!composed.contains(said), "the post says {said}"); } }
/// What was fought and when, which the result document does not carry. #[test] fn the_page_says_what_was_played() { let html = page(); assert!(html.contains("Scenario"), "how it was reached"); // "1-FirstRun.mms" is a file in a library; "First Run" is what it is // called, which is what a reader who was not there needs. assert!(html.contains("First Run"), "the scenario, said as a name"); assert!(html.contains("2026-08-19"), "the day"); }
/// A force's camo reaches its machines. Without this the whole painting /// path can rot silently: the card still draws, in MegaMek's grey. #[test] fn a_camo_paints_the_machines() { // A force with machines in it: the base fixture has none, and a card // with nothing to paint is the same card either way. let mut doc = result(); doc["players"][0]["units"] = serde_json::json!([damaged_griffin()]); let summary = summarise(&doc).expect("a summary"); // One flat red camo, which is the easiest thing to see in the pixels. let camo = crate::card::flat_camo([220, 40, 40]); // A machine has to have a picture before a camo can be painted onto // it, and no picture is fetched without an index. Grey, because the // camo rule only touches grey pixels. let mut art = std::collections::HashMap::new(); art.insert( summary.forces[0].units[0]["name"] .as_str() .expect("the machine is named") .to_owned(), std::sync::Arc::new(crate::card::flat_camo([128, 128, 128])), ); let painted = |camo: Vec<Option<Vec<u8>>>| { card::draw(&compose( &summary, Drawn { layout: card::Layout::Dossier, brief: &Brief::default(), eyebrow: "SCENARIO: FIRST RUN", verdict: "VICTORY", title: "@a.example won", sub: "First Run", perspective: None, camo: &camo, board: None, art: &art, }, )) .expect("the card draws") }; let plain = painted(summary.forces.iter().map(|_| None).collect()); let red = painted( summary .forces .iter() .enumerate() .map(|(i, _)| if i == 0 { Some(camo.clone()) } else { None }) .collect(), ); assert_ne!(plain, red, "the camo changed nothing"); }
/// Machine `n`'s picture belongs in machine `n`'s column. /// /// The lance card writes a name under each machine, and it used to draw /// the pictures on one grid and the names on another - packed at 150 /// pixels against a name column of 272 - so from the second machine on, a /// picture sat under somebody else's name, 366 pixels out by the fourth. /// /// Checked by moving one machine at a time: swap machine `n`'s art for /// something that draws differently, and every pixel that changes has to /// fall inside the column its name is written in. That says the picture /// and the word line up without repeating the arithmetic that decides /// where either of them goes - which is the mistake being guarded here. #[test] fn each_machine_is_drawn_in_its_own_column() { let machine = |name: &str, tons: f64| { let mut unit = damaged_griffin(); unit["name"] = name.into(); unit["tons"] = tons.into(); unit["kind"] = "mek".into(); unit["status"] = "active".into(); unit }; // Four, which is the most the card names and so the worst case. let names = [ "Atlas AS7-D", "Griffin GRF-1N", "Warhammer WHM-6R", "Locust LCT-1V", ]; let mut doc = result(); doc["players"][0]["units"] = serde_json::Value::Array(names.iter().map(|n| machine(n, 55.0)).collect()); let summary = summarise(&doc).expect("a summary"); let camo: Vec<Option<Vec<u8>>> = summary.forces.iter().map(|_| None).collect(); // The lance card is one force from its own player's point of view. let perspective = Perspective::read(Some("a.example")).expect("a point of view");
let render = |art: &std::collections::HashMap<String, std::sync::Arc<Vec<u8>>>| { let bytes = card::draw(&compose( &summary, Drawn { layout: card::Layout::Lance, brief: &Brief::default(), eyebrow: "SCENARIO: FIRST RUN", verdict: "VICTORY", title: "@a.example won", sub: "First Run", perspective: Some(&perspective), camo: &camo, board: None, art, }, )) .expect("the card draws"); let decoder = png::Decoder::new(std::io::Cursor::new(bytes)); let mut reader = decoder.read_info().expect("the card is a png"); let mut buf = vec![0; reader.output_buffer_size().expect("a sized frame")]; let info = reader.next_frame(&mut buf).expect("a frame"); let rgba = crate::png_util::to_rgba( &buf[..info.buffer_size()], info.color_type, info.width, info.height, ) .expect("rgba"); (info.width as usize, rgba) };
let (width, plain) = render(&no_art()); // Every name column, by the card's own rule: the drawing width split // evenly between the machines. let column = crate::card::lance_column(names.len());
for (index, name) in names.iter().enumerate() { // A silhouette that is not the one this machine would pick, so // the pixels that move are this machine's and nothing else's. let mut art = std::collections::HashMap::new(); art.insert( (*name).to_owned(), std::sync::Arc::new(crate::card::flat_camo([10, 200, 90])), ); let (_, moved) = render(&art); assert_ne!(plain, moved, "swapping {name}'s art changed nothing");
let lo = crate::card::lance_column_x(index, names.len()); let hi = lo + column; let stray: Vec<i32> = plain .chunks_exact(4) .zip(moved.chunks_exact(4)) .enumerate() .filter(|(_, (a, b))| a != b) .map(|(offset, _)| (offset % width) as i32) .filter(|x| *x < lo || *x >= hi) .collect(); assert!( stray.is_empty(), "{name} is machine {index}, whose name is written in [{lo}, {hi}), but changing its picture moved {} pixels outside that column (x from {:?} to {:?})", stray.len(), stray.iter().min(), stray.iter().max(), ); } }
/// Every card, for all three shapes a match comes in, written out as /// PNGs to look at. Ignored like `preview`: this is for eyes. /// /// cargo test -p headquarters-api share::tests::cards -- --ignored #[test] #[ignore = "writes the cards to /tmp for eyeballing"] fn cards() { // A machine's silhouette follows its tonnage, so the shapes differ. let machine = |name: &str, tons: f64, kind: &str, dead: bool| { let mut unit = damaged_griffin(); unit["name"] = name.into(); unit["tons"] = tons.into(); unit["kind"] = kind.into(); unit["status"] = if dead { "destroyed" } else { "active" }.into(); unit }; let force = |name: &str, slot: &str, team: i64, left: i64, start: i64, bv: i64, units: Vec<serde_json::Value>| { serde_json::json!({ "id": 1, "name": name, "slot": slot, "observer": false, "team": team, "unitsRemaining": left, "unitsStart": start, "bvRemaining": bv, "bvStart": start * 2000, "units": units, }) };
let lance = |dead: usize| { let all = vec![ machine("Atlas AS7-D", 100.0, "mek", dead > 3), machine("Griffin GRF-1N", 55.0, "mek", dead > 2), machine("Vedette Medium Tank", 50.0, "vehicle", dead > 1), machine("Locust LCT-1V", 20.0, "mek", dead > 0), ]; all };
let company = || { let mut all = Vec::new(); for index in 0..12 { all.push(machine( ["Locust LCT-1V", "Wasp WSP-1A", "Stinger STG-3R"][index % 3], 20.0, "mek", index % 3 == 0, )); } all };
let cases: Vec<(&str, serde_json::Value, Option<&str>)> = vec![ // One player against Princess, which is what a daily challenge is. ( "1vbot", serde_json::json!({ "round": 7, "victoryTeam": 1, "victoryPlayerId": -1, "identity": {"players": [ {"slot": "TraineeA", "control": "human"}, {"slot": "OPFOR", "control": "bot"}, ]}, "players": [ force("@permadeath.com", "TraineeA", 1, 3, 4, 1420, lance(1)), force("@lance.blue", "OPFOR", 2, 0, 4, 0, lance(4)), ], }), Some("permadeath.com"), ), // Bug Company by its own faction names, so the daily card places // each force in the zone the scenario deploys it to. ( "daily", serde_json::json!({ "round": 9, "victoryTeam": 1, "victoryPlayerId": -1, "identity": {"players": [ {"slot": "Challenger", "control": "human"}, {"slot": "Talon Lance", "control": "bot"}, {"slot": "Hammer Lance", "control": "bot"}, {"slot": "Anvil Lance", "control": "bot"}, ]}, "players": [ force("@permadeath.com", "Challenger", 1, 1, 1, 3140, lance(0)), force("@lance.blue", "Talon Lance", 2, 1, 4, 420, lance(3)), force("@lance.blue", "Hammer Lance", 2, 0, 4, 0, lance(4)), force("@lance.blue", "Anvil Lance", 2, 2, 4, 780, lance(2)), ], }), Some("permadeath.com"), ), // Twelve machines against four: Bug Company, and the case a row // of four icons cannot draw. ( "company", serde_json::json!({ "round": 12, "victoryTeam": 1, "victoryPlayerId": -1, "identity": {"players": [ {"slot": "TraineeA", "control": "human"}, {"slot": "OPFOR", "control": "bot"}, ]}, "players": [ force("@permadeath.com", "TraineeA", 1, 3, 4, 1180, lance(1)), force("@lance.blue", "OPFOR", 2, 4, 12, 640, company()), ], }), Some("permadeath.com"), ), // Four corners, eight seats: the most a card can hold and still // name anybody. ( "4teams", serde_json::json!({ "round": 14, "victoryTeam": 3, "victoryPlayerId": -1, "players": [ force("@permadeath.com", "A1", 1, 1, 4, 300, lance(3)), force("@ally.example", "A2", 1, 0, 4, 0, lance(4)), force("@c.example", "B1", 2, 2, 4, 900, lance(2)), force("@d.example", "B2", 2, 1, 4, 420, lance(3)), force("@e.example", "C1", 3, 4, 4, 1900, lance(0)), force("@f.example", "C2", 3, 3, 4, 1500, lance(1)), force("@g.example", "D1", 4, 0, 4, 0, lance(4)), force("@h.example", "D2", 4, 1, 4, 260, lance(3)), ], }), Some("permadeath.com"), ), // Handles are domain names, and some people own long ones. This // is the case that finds a card which only shrinks and never // trims. ( "longnames", serde_json::json!({ "round": 6, "victoryTeam": 1, "victoryPlayerId": -1, "identity": {"players": [ {"slot": "TraineeA", "control": "human"}, {"slot": "OPFOR", "control": "bot"}, ]}, "players": [ force( "@extraordinarily-long-handle.solaris-seven.example.com", "TraineeA", 1, 3, 4, 1500, vec![ machine("Marauder II MAD-4A Bounty Hunter", 100.0, "mek", false), machine("Warhammer WHM-6R Black Widow", 70.0, "mek", false), machine("Shadow Hawk SHD-2H Custom", 55.0, "mek", true), machine("Pegasus Scout Hover Tank PGS-3D", 35.0, "vehicle", false), ], ), force("@lance.blue", "OPFOR", 2, 0, 4, 0, lance(4)), ], }), Some("extraordinarily-long-handle.solaris-seven.example.com"), ), // Two people. ( "1v1", serde_json::json!({ "round": 11, "victoryTeam": 2, "victoryPlayerId": -1, "players": [ force("@permadeath.com", "TraineeA", 1, 1, 4, 480, lance(3)), force("@a.example", "TraineeB", 2, 3, 4, 1610, lance(1)), ], }), Some("permadeath.com"), ), // Four, two a side: the case "@a beat @b" cannot say at all. ( "2v2", serde_json::json!({ "round": 9, "victoryTeam": 1, "victoryPlayerId": -1, "players": [ force("@permadeath.com", "TraineeA", 1, 2, 4, 1180, lance(2)), force("@ally.example", "TraineeB", 1, 4, 4, 1890, lance(0)), force("@a.example", "TraineeC", 2, 0, 4, 0, lance(4)), force("@b.example", "TraineeD", 2, 1, 4, 320, lance(3)), ], }), Some("permadeath.com"), ), ];
for (shape, result, who) in cases { let summary = summarise(&result).expect("a summary"); let perspective = Perspective::read(who); let verdict = verdict_line(&summary, perspective.as_ref()); // The context is per layout, below: a daily card is drawn against // a daily's own. for layout in [ card::Layout::Dossier, card::Layout::Lance, card::Layout::Duel, card::Layout::Daily, card::Layout::Battlefield, ] { // A duel card only means anything with two forces in it. if layout == card::Layout::Duel && summary.forces.len() != 2 { continue; } // A camo on the first force when CARD_CAMO names one, so the // painting path can be looked at as well as asserted on: // CARD_CAMO=/path/to/camo.png cargo test ... -- --ignored let sample = std::env::var("CARD_CAMO") .ok() .and_then(|path| std::fs::read(path).ok()); // And a board when CARD_BOARD names one, which is the only // way to look at the battlefield card. let ground = std::env::var("CARD_BOARD") .ok() .and_then(|path| std::fs::read(path).ok()); let camo: Vec<Option<Vec<u8>>> = summary .forces .iter() .enumerate() .map(|(index, _)| if index == 0 { sample.clone() } else { None }) .collect(); // A daily card is drawn against a daily's own context, which // is where its eyebrow, its name and its date come from. let context = if layout == card::Layout::Daily { daily_context() } else { fixture_context() }; let (title, sub) = headline(layout, &context, &verdict, summary.round); let designation = context.designation(layout); let brief = context.brief(); let drawn = card::draw(&compose( &summary, Drawn { layout, brief: &brief, eyebrow: &designation, verdict: verdict_word(&summary, perspective.as_ref()), title: &title, sub: &sub, perspective: perspective.as_ref(), camo: &camo, board: ground.clone(), art: &local_art(&summary), }, )) .expect("the card draws"); let path = format!("/tmp/card-{shape}-{}.png", layout.slug()); std::fs::write(&path, drawn).expect("written"); println!("wrote {path}"); } } }
/// The daily card is the front page's card: it reads the challenge's own /// description and each faction's deploy zone out of the scenario file, /// through the same crate the site's index is built with. #[test] fn a_daily_reads_its_own_scenario() { let context = daily_context(); let brief = context.brief(); assert!( brief.blurb.contains("pirate lances"), "the challenge's own description: {}", brief.blurb ); // The zones the front page stands each force in. assert_eq!(brief.zone_of("Challenger").as_deref(), Some("CTR")); assert_eq!(brief.zone_of("Talon Lance").as_deref(), Some("N")); assert_eq!(brief.zone_of("Hammer Lance").as_deref(), Some("SW")); assert_eq!(brief.zone_of("Anvil Lance").as_deref(), Some("SE")); // A scenario this service does not carry has none of it, and the card // draws without. assert!(fixture_context().brief().blurb.is_empty()); }
/// The tags an unfurler reads, and the one that decides whether a post /// carries a picture at all. #[test] fn the_page_carries_a_card() { let html = page(); assert!(html.contains( "<meta property=\"og:image\" content=\"https://lance.blue/reports/m1/card.png\">" )); assert!(html.contains("<meta name=\"twitter:card\" content=\"summary_large_image\">")); assert!( html.contains("<meta property=\"og:title\" content=\"@a.example beat @lance.blue\">") ); assert!( html.contains("<meta property=\"og:url\" content=\"https://lance.blue/reports/m1\">") ); }
/// Prints the page, for looking at it. Ignored by default: /// /// cargo test -p headquarters-api share -- --ignored --nocapture preview #[test] #[ignore = "prints the share page for eyeballing"] fn preview() { println!("{}", page()); }
/// A match that produced no forces has nothing to show. The page answers /// 404 rather than an empty card with a title over it. #[test] fn an_empty_result_is_no_page() { let doc = serde_json::json!({"round": 1, "players": []}); assert!(summarise(&doc).is_none()); }
const ALL_MISSING: [Missing; 4] = [ Missing::Unknown, Missing::Unfinished, Missing::Resultless, Missing::Unreadable, ];
/// A lookup that failed says nothing about the match, so it must not be /// answered - or cached - as an absent one. #[test] fn a_failed_lookup_is_not_an_absent_match() { assert!(Missing::Unreadable.may_yet()); assert_eq!( Missing::Unreadable.status(), StatusCode::SERVICE_UNAVAILABLE ); assert_eq!( refusal_headers(Missing::Unreadable) .get(header::CACHE_CONTROL) .unwrap(), "no-store" ); }
/// The board is the battlefield card's picture and nothing else's. Every /// other layout draws the same bytes whether a board exists or not - the /// two used to be stacked, and a drawn map showing through a rendered one /// is noise. #[test] fn only_the_battlefield_card_uses_the_board() { let doc = result(); let summary = summarise(&doc).expect("a summary"); let context = fixture_context(); let brief = context.brief(); let ground = std::fs::read("assets/art/battlespace-hex.png").expect("a stand-in board"); let camo: Vec<Option<Vec<u8>>> = summary.forces.iter().map(|_| None).collect(); let draw = |layout, board: Option<Vec<u8>>| { card::draw(&compose( &summary, Drawn { layout, brief: &brief, eyebrow: "SCENARIO: FIRST RUN", verdict: "", title: "a match", sub: "", perspective: None, camo: &camo, board, art: &no_art(), }, )) .expect("the card draws") }; for layout in [ card::Layout::Dossier, card::Layout::Duel, card::Layout::Daily, ] { assert_eq!( draw(layout, None), draw(layout, Some(ground.clone())), "{} changed when a board was supplied", layout.slug() ); } // And the battlefield card is the one that does change. assert_ne!( draw(card::Layout::Battlefield, None), draw(card::Layout::Battlefield, Some(ground.clone())), "the battlefield card ignored the board" ); }
/// The gallery is one slide per card this match can draw, and each slide /// posts the card it is showing. The address is what carries the choice, /// so what is on screen, what is copied and what an unfurler fetches are /// the same card. #[test] fn every_card_gets_a_slide_that_posts_itself() { let html = page(); let context = fixture_context(); let summary = summarise(&result()).expect("a summary"); let offered = layouts(&context, &summary, false, false); assert!(offered.len() > 1, "a fixture with something to choose"); for layout in &offered { let slug = layout.slug(); assert!( html.contains(&format!("id=\"card-{slug}\"")), "no slide for {slug}" ); // Its own picture, and its own composer aimed at its own address. assert!( html.contains(&format!("card.png?card={slug}")), "no picture for {slug}" ); assert!( html.contains(&urlencode(&format!( "https://lance.blue/reports/m1?card={slug}" ))), "no composer for {slug}" ); } // One slide stands before anything is pressed, and it is the chosen // one rather than whichever happens to be first in the document. assert_eq!(html.matches(" checked ").count(), 1); let chosen = format!("id=\"pick-{}\" checked", offered[0].slug()); assert!( html.contains(&chosen), "the opening slide is not the chosen one" ); }
/// Both halves of the ask travel together. The card decides which picture /// is drawn; the perspective decides whose side it is drawn from - the /// difference between VICTORY and "@them beat @you" on the same match. A /// slide that carried only one of them would post somebody else's verdict. #[test] fn every_slide_carries_the_card_and_the_point_of_view() { let summary = summarise(&result()).expect("a summary"); let context = fixture_context(); let perspective = Perspective::read(Some("@a.example")).expect("a side"); let offered = layouts(&context, &summary, true, true); let html = share_menu(&Page { here: "https://lance.blue/reports/m1?perspective=a.example", base: "https://lance.blue/reports/m1", image: "https://lance.blue/reports/m1/card.png", home: "https://lance.blue", perspective: Some(&perspective), offered: &offered, chosen: offered[0], participant: true, units: None, }); for layout in &offered { let query = format!("?perspective=a.example&card={}", layout.slug()); // The picture on the slide. Escaped, because it is an attribute. assert!( html.contains(&escape(&format!("card.png{query}"))), "{} lost the ask on its picture", layout.slug() ); // And the address its composer posts, which is what an unfurler // will fetch and draw from. assert!( html.contains(&urlencode(&format!("https://lance.blue/reports/m1{query}"))), "{} lost the ask in its post", layout.slug() ); } }
/// Turning the gallery must not move the page. A fragment link scrolls /// the targeted slide to the top of the viewport, so the arrows are /// labels for a radio group and nothing in the block is an anchor to /// itself - which is not visible to any test that only reads markup, so /// the shape of the rule is asserted alongside it. #[test] fn turning_the_gallery_does_not_move_the_page() { assert!( STYLE.contains(".pickr:checked+.slide{display:block}"), "a slide is shown by its own radio" ); assert!( !STYLE.contains(".slide:target"), "the gallery is back on fragments, and the page will jump" ); let html = page(); let block = &html[html.find("<div class=\"share\">").expect("a share block")..]; let block = &block[..block.find("</div></div>").expect("an end") + 12]; assert!( !block.contains("href=\"#"), "a fragment link inside the gallery" ); }
/// The composer belongs to the player the match handed here. A reader who /// arrived from the post gets the report and no way to repost it - and /// the flag that says which is which must never reach a link, or the /// first person to open a shared post inherits it. #[test] fn only_a_participant_is_offered_the_composer() { let summary = summarise(&result()).expect("a summary"); let context = fixture_context(); let offered = layouts(&context, &summary, false, false); let menu = |participant| { share_menu(&Page { here: "https://lance.blue/reports/m1", base: "https://lance.blue/reports/m1", image: "https://lance.blue/reports/m1/card.png", home: "https://lance.blue", perspective: None, offered: &offered, chosen: offered[0], participant, units: None, }) }; assert!(menu(true).contains("Post report to Bluesky")); assert!(menu(false).is_empty(), "a reader was offered the composer");
// And the flag is not part of the ask that links are rebuilt from. let ask = Ask { perspective: Some("a.example".into()), card: Some("duel".into()), participant: Some("1".into()), }; let query = ask.query(); assert!(query.contains("perspective=a.example")); assert!(query.contains("card=duel")); assert!( !query.contains("participant"), "the participant flag reached a link: {query}" ); }
/// How long the match took, which the API measures itself: the row is /// stamped when the container reports the match playable and again when /// it reports it decided. #[test] fn the_footer_says_how_long_it_took() { assert_eq!(elapsed(42), "42s"); assert_eq!(elapsed(60), "1m"); assert_eq!(elapsed(11 * 60), "11m"); assert_eq!(elapsed(72 * 60), "1h 12m"); // The fixture row runs eleven minutes. let line = fixture_context().footer_line(7); assert!(line.ends_with("11m"), "no duration in {line}"); assert!(line.contains("7 rounds"), "lost the rounds: {line}"); }
/// A match still being fought, or one from before the columns existed, /// simply says nothing rather than "0m". #[test] fn an_untimed_match_says_nothing_about_time() { let row = crate::db::MatchRow { id: "m1".into(), owner_did: "did:plc:a".into(), status: "over".into(), token: "t".into(), task_arn: None, task_ip: None, created_at: 1_787_097_600, scenario: Some("data/scenarios/TrainingScenarios/1-FirstRun.mms".into()), started_at: Some(1_787_097_600), ended_at: None, }; let line = Context::read(&row, &[]).footer_line(3); assert!(line.ends_with("3 rounds"), "invented a duration: {line}"); }
/// Princess fights under the site's own handle, which is also a real /// account. Every layout has to say which one took the field - the duel /// plate was the one that did not, so a 1v1 against the bot read as a /// match against @lance.blue the player. #[test] fn every_layout_marks_a_bot_seat() { let doc = result(); let summary = summarise(&doc).expect("a summary"); let context = fixture_context(); let brief = context.brief(); let camo: Vec<Option<Vec<u8>>> = summary.forces.iter().map(|_| None).collect(); let draw = |layout, bot: bool| { let mut summary = summarise(&doc).expect("a summary"); for force in &mut summary.forces { force.bot = bot; } card::draw(&compose( &summary, Drawn { layout, brief: &brief, eyebrow: "SCENARIO: FIRST RUN", verdict: "", title: "a match", sub: "", perspective: None, camo: &camo, board: None, art: &no_art(), }, )) .expect("the card draws") }; // The two that name every side. The lance card is one force from // somebody's own point of view, which is never the bot's. for layout in [card::Layout::Dossier, card::Layout::Duel] { assert_ne!( draw(layout, false), draw(layout, true), "{} says nothing about a bot seat", layout.slug() ); } }
/// The page is set in the site's faces, from a copy - the image is built /// without `web/`. Same guard as the silhouettes. #[test] fn fonts_match_the_sites() { let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../web/src/fonts"); if !root.is_dir() { return; } for (name, bytes) in FONTS { let theirs = std::fs::read(root.join(format!("{name}-latin.woff2"))) .unwrap_or_else(|e| panic!("{name} is embedded but not in web/: {e}")); assert_eq!(theirs, bytes, "{name} differs from the site's copy"); } }
/// Nothing to turn when there is nothing to turn to. #[test] fn a_single_card_has_no_arrows() { let summary = summarise(&result()).expect("a summary"); let context = fixture_context(); let only = [card::Layout::Dossier]; let html = share_menu(&Page { here: "https://lance.blue/reports/m1", base: "https://lance.blue/reports/m1", image: "https://lance.blue/reports/m1/card.png", home: "https://lance.blue", perspective: None, offered: &only, chosen: card::Layout::Dossier, participant: true, units: None, }); let _ = (&summary, &context); assert!(!html.contains("class=\"turn"), "arrows with one card"); // The single slide still shows: its own radio is the checked one. assert!(html.contains(" checked "), "nothing shows the only card"); assert!(html.contains("Post report to Bluesky"), "still postable"); }
/// The row's status is what separates "not yet" from "not ever". #[test] fn a_live_match_is_unfinished_and_a_dead_one_is_not() { assert_eq!(Missing::read("starting"), Missing::Unfinished); assert_eq!(Missing::read("running"), Missing::Unfinished); assert_eq!(Missing::read("ready"), Missing::Unfinished); assert_eq!(Missing::read("over"), Missing::Resultless); assert_eq!(Missing::read("failed"), Missing::Resultless); // An unknown word is not a live match, so it does not wait forever. assert_eq!(Missing::read("wat"), Missing::Resultless); }
/// The refusal is a page, not a blank tab, and says which of the three /// happened. #[test] fn a_refusal_reads_as_a_page() { for missing in ALL_MISSING { let html = notice(missing, "https://lance.blue"); assert!(html.starts_with("<!doctype html>")); assert!(html.contains(missing.heading())); assert!(html.contains(missing.line())); // Nothing to index, and nothing for an unfurler to draw. assert!(html.contains("<meta name=\"robots\" content=\"noindex\">")); assert!(!html.contains("og:image")); assert!(!html.contains("card.png")); // And a way out. assert!(html.contains("href=\"https://lance.blue\"")); } }
/// A report that may still appear must not be cached as absent, and must /// not 404: the CDN turns a 404 into the site's own 404 page, and a card /// service that gets one records the link as having no preview. #[test] fn a_report_that_may_yet_appear_is_not_cached_as_gone() { for missing in [ Missing::Unfinished, Missing::Resultless, Missing::Unreadable, ] { assert!(missing.may_yet()); assert_eq!(missing.status(), StatusCode::SERVICE_UNAVAILABLE); let headers = refusal_headers(missing); assert_eq!(headers.get(header::CACHE_CONTROL).unwrap(), "no-store"); assert!(headers.contains_key(header::RETRY_AFTER)); } // A match nobody launched is the one permanent answer. assert!(!Missing::Unknown.may_yet()); assert_eq!(Missing::Unknown.status(), StatusCode::NOT_FOUND); assert!(!refusal_headers(Missing::Unknown).contains_key(header::RETRY_AFTER)); }
/// Only the match that is going to become a report looks again. #[test] fn only_an_unfinished_match_refreshes() { assert!( notice(Missing::Unfinished, "https://lance.blue").contains("http-equiv=\"refresh\"") ); assert!( notice(Missing::Unreadable, "https://lance.blue").contains("http-equiv=\"refresh\"") ); for missing in [Missing::Unknown, Missing::Resultless] { assert!(!notice(missing, "https://lance.blue").contains("http-equiv")); } }
#[test] #[ignore = "prints the three refusals for eyeballing"] fn preview_refusals() { for missing in ALL_MISSING { println!("{}", notice(missing, "https://lance.blue")); } }}