use crate::lzw;
const ALPHABET: &[u8; 32] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ";
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Kind {
Beach,
Level,
Round,
}
impl Kind {
fn letter(self) -> char {
match self {
Kind::Beach => 'B',
Kind::Level => 'L',
Kind::Round => 'R',
}
}
fn from_letter(letter: char) -> Option<Kind> {
match letter {
'B' => Some(Kind::Beach),
'L' => Some(Kind::Level),
'R' => Some(Kind::Round),
_ => None,
}
}
}
const GROUP: usize = 5;
pub fn encode(kind: Kind, payload: &[u8]) -> String {
let packed = lzw::compress(payload, 8);
let mut body = base32_encode(&packed);
body.push(ALPHABET[usize::from(checksum(&body))] as char);
let mut out = format!("PP{}", kind.letter());
for (i, ch) in body.chars().enumerate() {
if i > 0 && i.is_multiple_of(GROUP) {
out.push('-');
}
out.push(ch);
}
out
}
pub fn decode(code: &str) -> Option<(Kind, Vec<u8>)> {
let mut chars = code.chars().filter(|ch| !matches!(ch, '-' | ' ' | '\t'));
let (p, q) = (chars.next()?, chars.next()?);
if !p.eq_ignore_ascii_case(&'P') || !q.eq_ignore_ascii_case(&'P') {
return None;
}
let kind = Kind::from_letter(chars.next()?.to_ascii_uppercase())?;
let body: String = chars.map(tidy).collect();
let (digits, check) = body.split_at_checked(body.len().checked_sub(1)?)?;
if check.chars().next()? != ALPHABET[usize::from(checksum(digits))] as char {
return None;
}
let packed = base32_decode(digits)?;
let payload = lzw::decompress(&packed, 8)?;
Some((kind, payload))
}
fn tidy(ch: char) -> char {
match ch.to_ascii_uppercase() {
'I' | 'L' => '1',
'O' => '0',
'U' => 'V',
other => other,
}
}
fn checksum(body: &str) -> u8 {
let mut sum = 0u32;
for (i, ch) in body.chars().enumerate() {
let value = index_of(ch).map_or(0, u32::from);
let weight = 2 * (i as u32 % 16) + 1;
sum = sum.wrapping_add(value.wrapping_mul(weight));
}
(sum % 32) as u8
}
fn index_of(ch: char) -> Option<u8> {
ALPHABET
.iter()
.position(|&a| a == ch as u8)
.map(|index| index as u8)
}
fn base32_encode(bytes: &[u8]) -> String {
let mut out = String::new();
let (mut acc, mut bits) = (0u32, 0u8);
for &byte in bytes {
acc = (acc << 8) | u32::from(byte);
bits += 8;
while bits >= 5 {
bits -= 5;
out.push(ALPHABET[((acc >> bits) & 0x1F) as usize] as char);
}
}
if bits > 0 {
out.push(ALPHABET[((acc << (5 - bits)) & 0x1F) as usize] as char);
}
out
}
fn base32_decode(text: &str) -> Option<Vec<u8>> {
let mut out = Vec::new();
let (mut acc, mut bits) = (0u32, 0u8);
for ch in text.chars() {
acc = (acc << 5) | u32::from(index_of(ch)?);
bits += 5;
if bits >= 8 {
bits -= 8;
out.push(((acc >> bits) & 0xFF) as u8);
}
}
Some(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn codes_round_trip_at_every_size() {
for payload in [
vec![],
vec![0u8],
b"wrap: on\n".to_vec(),
(0..500u32).map(|i| (i % 251) as u8).collect(),
] {
for kind in [Kind::Beach, Kind::Level, Kind::Round] {
let code = encode(kind, &payload);
assert_eq!(decode(&code), Some((kind, payload.clone())), "{code}");
}
}
}
#[test]
fn a_retyped_code_still_reads() {
let code = encode(Kind::Level, b"a small beach");
let expected = decode(&code).expect("the code as written");
for variant in [
code.to_lowercase(),
code.replace('-', ""),
code.replace('-', " "),
format!(" {code} ").replace(' ', ""),
] {
assert_eq!(decode(&variant), Some(expected.clone()), "{variant}");
}
let muddled: String = code
.chars()
.map(|ch| match ch {
'1' => 'I',
'0' => 'O',
other => other,
})
.collect();
assert_eq!(decode(&muddled), Some(expected), "{muddled}");
}
#[test]
fn a_typo_is_refused() {
for payload in [b"seed 12345".as_slice(), b"a", b"wrap: on\n"] {
let code = encode(Kind::Beach, payload);
assert!(decode(&code).is_some(), "the code itself is good");
let chars: Vec<char> = code.chars().collect();
for (at, &original) in chars.iter().enumerate() {
if at < 3 || original == '-' {
continue;
}
for replacement in ALPHABET.iter().map(|&b| b as char) {
if replacement == original {
continue;
}
let mut typo = chars.clone();
typo[at] = replacement;
let typo: String = typo.into_iter().collect();
assert_eq!(decode(&typo), None, "{original}->{replacement} in {code}");
}
}
}
}
#[test]
fn a_swapped_pair_is_refused() {
let code = encode(Kind::Beach, b"seed 12345");
let body: Vec<char> = code.chars().filter(|c| *c != '-').collect();
let mut swaps = 0;
for at in 3..body.len() - 1 {
let (a, b) = (body[at], body[at + 1]);
let apart = index_of(a).unwrap().abs_diff(index_of(b).unwrap());
if a == b || apart == 16 {
continue;
}
let mut swapped = body.clone();
swapped.swap(at, at + 1);
let swapped: String = swapped.into_iter().collect();
assert_eq!(decode(&swapped), None, "swap at {at} of {code}");
swaps += 1;
}
assert!(swaps > 0, "the sample code had no adjacent pair to swap");
}
#[test]
fn what_is_not_a_code_is_refused() {
for junk in [
"",
"PP",
"PPX-12345",
"hello",
"PPB", "12345-67890",
] {
assert_eq!(decode(junk), None, "{junk}");
}
}
#[test]
fn a_repetitive_payload_shrinks() {
let round = "000000 000000 000000 000000\n".repeat(400);
let code = encode(Kind::Round, round.as_bytes());
assert!(
code.len() < round.len() / 8,
"{} characters for {} bytes",
code.len(),
round.len()
);
assert_eq!(
decode(&code).map(|(_, bytes)| bytes),
Some(round.into_bytes())
);
}
}