initial commit

main
kirbylife 2024-09-03 12:27:27 -06:00
commit 569a7ec7ab
9 changed files with 2692 additions and 0 deletions

3
.gitignore vendored 100644
View File

@ -0,0 +1,3 @@
/target
Cargo.lock
*.pem

14
Cargo.toml 100644
View File

@ -0,0 +1,14 @@
[package]
name = "worsdle_gemini"
version = "0.1.0"
edition = "2021"
[dependencies]
chrono = { version = "0.4.38", features = ["serde"] }
glob = "0.3.1"
serde = { version = "1.0.208", features = ["derive"] }
tera = "1.20.0"
tokio = { version = "1.39.2", features = ["full"] }
urlencoding = "2.1.3"
uuid = { version = "1.10.0", features = ["v4", "fast-rng"] }
windmark = { version = "0.3.11", features = ["response-macros", "logger"] }

35
src/attempt.rs 100644
View File

@ -0,0 +1,35 @@
use crate::status::Status;
use serde::Serialize;
#[derive(Serialize, Clone)]
pub struct Character {
pub char: char,
pub status: Status,
}
#[derive(Serialize, Clone)]
pub struct Attempt {
pub chars: Vec<Character>,
}
impl Attempt {
pub fn from_string(text: &String, answer: &String) -> Self {
let mut output = vec![];
for (n, ch) in text.to_uppercase().chars().enumerate() {
let answer_nth = answer
.chars()
.nth(n)
.expect("Answer and text has different length");
let status = if ch == answer_nth {
Status::Found
} else if answer.contains(ch) {
Status::Almost
} else {
Status::NotFound
};
output.push(Character { char: ch, status })
}
Attempt { chars: output }
}
}

131
src/gameslot.rs 100644
View File

@ -0,0 +1,131 @@
use crate::attempt::Attempt;
use crate::status::Status;
use crate::words::get_word_of_the_day;
use std::collections::{HashMap, VecDeque};
use uuid::Uuid;
pub type Board = [Option<Attempt>; 6];
#[derive(Clone)]
pub enum InsertionStatus {
Ok,
LengthError,
GameOver,
}
#[derive(Clone, Debug)]
pub enum GameStatus {
Playing,
Win(String),
Fail,
}
#[derive(Clone)]
pub struct Game {
pub uuid: Uuid,
pub attempt_index: usize,
pub answer: String,
pub board: Board,
pub status: GameStatus,
pub last_insertion_status: InsertionStatus,
}
impl Game {
pub async fn new() -> Self {
Game {
uuid: Uuid::new_v4(),
attempt_index: 0,
answer: get_word_of_the_day().await,
board: Default::default(),
status: GameStatus::Playing,
last_insertion_status: InsertionStatus::Ok,
}
}
pub fn add_attempt(&mut self, word: &String) -> InsertionStatus {
let word = word.to_uppercase();
let win = word.eq(&self.answer);
let insertion_status = match (self.attempt_index, word.chars().count(), &self.status) {
(0..=5, 5, &GameStatus::Playing) => {
let new_attempt = Attempt::from_string(&word, &self.answer);
self.board[self.attempt_index] = Some(new_attempt);
self.attempt_index += 1;
match (win, self.attempt_index) {
(true, _) => {
self.status = GameStatus::Win(self.generate_result_pattern());
InsertionStatus::GameOver
}
(false, 6) => {
self.status = GameStatus::Fail;
InsertionStatus::GameOver
}
(_, _) => InsertionStatus::Ok,
}
}
(0..=5, _, &GameStatus::Playing) => InsertionStatus::LengthError,
(_, _, &GameStatus::Win(_) | &GameStatus::Fail) => InsertionStatus::GameOver,
_ => todo!(),
};
self.last_insertion_status = insertion_status.clone();
insertion_status
}
fn generate_result_pattern(&self) -> String {
let mut output = String::new();
for attempt in &self.board {
match attempt {
Some(att) => {
for ch in &att.chars {
output.push(match ch.status {
Status::Found => '🟩',
Status::Almost => '🟨',
Status::NotFound => '⬛',
});
}
output.push('\n');
}
None => {}
}
}
output
}
}
pub struct SlotManager {
max_length: usize,
slots: HashMap<Uuid, Game>,
order: VecDeque<Uuid>,
}
impl SlotManager {
pub fn new(max_length: usize) -> Self {
SlotManager {
max_length,
slots: HashMap::new(),
order: VecDeque::new(),
}
}
pub async fn new_game(&mut self) -> &mut Game {
if self.order.len() >= self.max_length {
if let Some(oldest_id) = self.order.pop_front() {
self.slots.remove(&oldest_id);
}
}
let new_game = Game::new().await;
let new_id = new_game.uuid;
self.slots.insert(new_id, new_game);
self.order.push_back(new_id);
// This should be safe because the game is inserted previously
self.slots.get_mut(&new_id).unwrap()
}
pub fn get_game(&mut self, id: Uuid) -> Option<&mut Game> {
self.slots.get_mut(&id)
}
}

182
src/main.rs 100644
View File

@ -0,0 +1,182 @@
mod attempt;
mod gameslot;
mod status;
mod words;
use crate::attempt::Attempt;
use crate::gameslot::{Board, Game};
use crate::words::get_word_of_the_day;
use gameslot::{GameStatus, InsertionStatus, SlotManager};
use tera::Tera;
use tokio::sync::Mutex;
use tokio::sync::OnceCell;
use uuid::Uuid;
static TERA: OnceCell<Tera> = OnceCell::const_new();
async fn render<S: AsRef<str>>(name: S, context: &tera::Context) -> Result<String, ()> {
let tera = TERA
.get_or_init(|| async {
let mut tera = Tera::default();
for path in glob::glob("templates/*").unwrap().flatten() {
let raw_path = path.clone();
let filename = raw_path
.file_name()
.unwrap() // This should be safe (?
.to_str()
.unwrap() // ._.?
.split('.')
.next();
tera.add_template_file(path, filename).unwrap()
}
tera
})
.await;
tera.render(name.as_ref(), context).map_err(|_| ())
}
static GAMES: OnceCell<Mutex<SlotManager>> = OnceCell::const_new();
async fn init_games() -> Result<(), ()> {
let slot_manager = SlotManager::new(100);
GAMES.set(Mutex::new(slot_manager)).map_err(|_| ())
}
async fn new_game() -> Uuid {
// This should be safe becasue the initialization is in the main
let slot_manager_mutex = GAMES.get().unwrap();
let mut slot_manager = slot_manager_mutex.lock().await;
let new_game = slot_manager.new_game().await;
new_game.uuid
}
async fn get_game(uuid: Uuid) -> Result<Game, ()> {
let slot_manager_mutex = GAMES.get().unwrap();
let mut slot_manager = slot_manager_mutex.lock().await;
match slot_manager.get_game(uuid) {
Some(game) => Ok(game.clone()),
None => Err(()),
}
}
async fn add_attempt(uuid: Uuid, attempt: &String) -> Option<InsertionStatus> {
let slot_manager_mutex = GAMES.get().unwrap();
let mut slot_manager = slot_manager_mutex.lock().await;
match slot_manager.get_game(uuid) {
Some(game) => Some(game.add_attempt(attempt)),
None => None,
}
}
#[windmark::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
init_games()
.await
.expect("Error trying to initialize the game slots");
windmark::router::Router::new()
.set_private_key_file("key.pem")
.set_certificate_file("cert.pem")
.set_languages(["es"])
.set_port(1970)
.enable_default_logger(true)
.set_fix_path(true)
.mount("/", move |_| async {
let game = new_game().await;
windmark::response::Response::temporary_redirect(format!("/{}", game))
// let mut context = tera::Context::new();
// let board: Board = Default::default();
// context.insert("board", &board);
// windmark::response::Response::success(render("index", &context).await.unwrap())
})
.mount("/:uuid", move |request| async move {
let mut context = tera::Context::new();
// This should be safe because you can only get into the route with the right path
let possible_uuid = request.parameters.get("uuid").unwrap();
let board: Board;
let error: Option<String>;
let result_pattern: Option<String>;
match Uuid::parse_str(possible_uuid) {
Ok(uuid) => match get_game(uuid).await {
Ok(game) => {
board = game.board;
match game.status {
GameStatus::Playing => {
error = match game.last_insertion_status {
InsertionStatus::Ok | InsertionStatus::GameOver => None,
InsertionStatus::LengthError => {
Some("La palabra debe de ser de 5 letras".to_string())
}
};
result_pattern = None;
}
GameStatus::Fail => {
error = None;
result_pattern = Some(
"Palabra no encontrada :c\nVuelve mañana para otro reto"
.to_string(),
);
}
GameStatus::Win(pattern) => {
error = None;
result_pattern = Some(format!("Palabra encontrada en {}/6 intentos\n{}Vuelve mañana para otro reto",game.attempt_index, pattern))
}
}
}
Err(_) => {
board = Default::default();
error = Some(
"La partida a la que intentas acceder ya no existe o nunca existió"
.to_string(),
);
result_pattern = None;
}
},
Err(_) => {
board = Default::default();
error = Some("Identificador incorrecto".to_string());
result_pattern = None;
}
}
context.insert("board", &board);
context.insert("error", &error);
context.insert("result_pattern", &result_pattern);
// context.insert("error", "Partida no encontrada");
windmark::response::Response::success(render("index", &context).await.unwrap())
})
.mount("/:uuid/:attempt", move |request| async move {
let mut context = tera::Context::new();
let possible_uuid = request.parameters.get("uuid").unwrap();
let attempt = urlencoding::decode(request.parameters.get("attempt").unwrap()).unwrap().to_string();
let redirect = match Uuid::parse_str(possible_uuid) {
Ok(uuid) => match add_attempt(uuid, &attempt).await {
Some(_) => true,
None => false,
},
Err(_) => false,
};
if redirect {
windmark::response::Response::temporary_redirect(format!("/{}", possible_uuid))
} else {
let board: Board = Default::default();
let result_pattern: Option<String> = None;
let error = "La partida a la que intentas jugar no existe";
context.insert("board", &board);
context.insert("error", &error);
context.insert("result_pattern", &result_pattern);
windmark::response::Response::success(render("index", &context).await.unwrap())
}
})
.run()
.await
}

18
src/status.rs 100644
View File

@ -0,0 +1,18 @@
use serde::{Serialize, Serializer};
#[derive(Clone)]
pub enum Status {
NotFound,
Found,
Almost,
}
impl Serialize for Status {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
match self {
Status::NotFound => serializer.serialize_char('-'),
Status::Almost => serializer.serialize_char('!'),
Status::Found => serializer.serialize_char(' '),
}
}
}

29
src/words.rs 100644
View File

@ -0,0 +1,29 @@
use chrono::Datelike;
use tokio::sync::OnceCell;
static WORDS: OnceCell<Vec<String>> = OnceCell::const_new();
async fn get_words() -> Vec<String> {
let words = WORDS
.get_or_init(|| async {
include_str!("../words.txt")
.trim()
.lines()
.map(|w| w.trim().into())
.collect()
})
.await;
words.to_vec()
}
pub async fn get_word_of_the_day() -> String {
let words = get_words().await;
let date = chrono::Utc::now();
let year = date.year() as usize;
let month = date.month() as usize;
let day = date.day() as usize;
let index = year * month * day;
words[index % words.len()].clone()
}

View File

@ -0,0 +1,42 @@
# Worsdle en español
{% if error -%}
{{ error }}
{% endif -%}
```
╔═══╦═══╦═══╦═══╦═══╗
{% for attempt in board -%}
{%- if attempt -%}
║{% for char in attempt.chars %}{{ char.status }}{{ char.char }}{{ char.status }}║{% endfor -%}
{%- else -%}
║ ║ ║ ║ ║ ║
{%- endif -%}
{% if not loop.last %}
╠═══╬═══╬═══╬═══╬═══╣
{% endif -%}
{% endfor %}
╚═══╩═══╩═══╩═══╩═══╝
```
{% if result_pattern -%}
{{ result_pattern }}
{% endif -%}
## Indicaciones:
* -A- Letra no se encuentra en la palabra.
* !A! Letra se encuentra en la palabra pero en otro lugar.
* A Letra en el lugar correcto.
## Instrucciones:
* Para comenzar una partida nueva, ingresa a la URL sin ningún atributo o path.
* Cada que quieras ingresar un nuevo intento, escribe al final de la URL una diagonal seguido de tu palabra. Ejemplo: "/suelo".
* tienes 6 intentos para adivinar la palabra.
* La palabra es igual para todas las personas que visitan la página y cada día hay una palabra nueva.
## Info:
Este clon de wordle está hecho por kirbylife utilizando el lenguaje de programación Rust, el framework Windmark y el editor Emacs

2238
words.txt 100644

File diff suppressed because it is too large Load Diff