Compare commits

...

3 commits

Author SHA1 Message Date
1ff2aedc2d Improve styling 2023-04-14 19:06:30 -07:00
96b97ca820 Use mora instead of characters, e.g. しゅ 2023-04-14 18:51:01 -07:00
4076a4c556 Implement working database 2023-04-14 17:30:33 -07:00
6 changed files with 185 additions and 51 deletions

View file

@ -4,6 +4,21 @@ const input = document.querySelector("#shiritori-input");
const players = document.querySelector("#shiritori-players"); const players = document.querySelector("#shiritori-players");
let id = null; let id = null;
function displayWord(_word, end, delay) {
let { word, reading } = _word;
p = document.createElement("p");
p.innerHTML = reading || word === reading ? (word === "" ? reading : `<ruby>${word}<rp>(</rp><rt>${reading}<rt/><rp>)</rp></ruby>`) : word;
if (delay != 0) {
p.style.animationDelay = `${delay}s`;
}
if (end) {
div.append(p);
} else {
div.prepend(p);
}
div.scrollTop = div.offsetHeight;
}
ws.onmessage = e => { ws.onmessage = e => {
const { event, data } = JSON.parse(e.data); const { event, data } = JSON.parse(e.data);
switch (event) { switch (event) {
@ -12,14 +27,17 @@ ws.onmessage = e => {
break; break;
case "word": case "word":
let waiting = data.author === id; let waiting = data.author === id;
p = document.createElement("p"); displayWord(data.word, true, 0);
p.innerHTML = data.reading || data.word === data.reading ? (data.word === "" ? data.reading : `<ruby>${data.word}<rp>(</rp><rt>${data.reading}<rt/><rp>)</rp></ruby>`) : data.word; input.placeholder = waiting ? "Waiting for other players..." : `${data.next_mora}`;
div.appendChild(p);
div.scrollTop = div.offsetHeight;
input.placeholder = waiting ? "Waiting for other players..." : `${data.next_char}`;
input.disabled = waiting; input.disabled = waiting;
if (!waiting) input.focus(); if (!waiting) input.focus();
break; break;
case "history":
console.log(data);
for (let i = 0; i < data.words.length; i++) {
displayWord(data.words[i], false, 0.1 * i);
}
break;
case "playerCount": case "playerCount":
let otherPlayers = data.players - 1; let otherPlayers = data.players - 1;
players.innerHTML = `${otherPlayers === 0 ? "No" : otherPlayers} other player${otherPlayers === 1 ? "" : "s"} online.`; players.innerHTML = `${otherPlayers === 0 ? "No" : otherPlayers} other player${otherPlayers === 1 ? "" : "s"} online.`;

View file

@ -12,11 +12,28 @@ div#content {
} }
div#shiritori p { div#shiritori p {
margin: 0; margin: 0;
animation-duration: 1s;
animation-name: slidein;
animation-fill-mode: forwards;
opacity: 0;
}
@keyframes slidein {
from {
transform: translateX(-32px);
opacity: 0;
}
to {
transform: none;
opacity: 1;
}
} }
div#shiritori { div#shiritori {
padding: 2px; padding: 2px;
height: 8em; height: 24em;
font-size: 0.875em;
overflow-y: scroll; overflow-y: scroll;
text-align: center;
} }
div#shiritori, input#shiritori-input { div#shiritori, input#shiritori-input {
padding: 0.25em; padding: 0.25em;

View file

@ -6,6 +6,7 @@ use rusqlite::{Connection, Result, params};
pub struct Database { pub struct Database {
conn: Connection, conn: Connection,
pub last_word_id: i64,
} }
#[derive(From, Debug)] #[derive(From, Debug)]
@ -28,17 +29,26 @@ impl Database {
conn.execute( conn.execute(
"CREATE TABLE IF NOT EXISTS word ( "CREATE TABLE IF NOT EXISTS word (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
word TEXT, reading TEXT, word TEXT,
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, reading TEXT,
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP
)", )",
params![], params![],
)?; )?;
Ok(Self { conn }) let last_word_id = match conn
.prepare("SELECT id FROM word ORDER BY id DESC LIMIT 1")?
.query_map(params![], |row| row.get(0))?
.collect::<Result<Vec<i64>>>()?
.first() {
Some(id) => *id,
None => 0, // first database entry is id 1
};
Ok(Self { conn, last_word_id })
} }
pub fn load_words_before(&self, before_id: i64) -> Result<Vec<Word>> { pub fn load_words_before(&self, before_id: i64) -> Result<Vec<Word>> {
self.conn self.conn
.prepare("SELECT id, word, reading, timestamp FROM word WHERE id < ? DESC LIMIT 10")? .prepare("SELECT id, word, reading, timestamp FROM word WHERE id < ? ORDER BY id DESC LIMIT 10")?
.query_map(params![before_id], |row| { .query_map(params![before_id], |row| {
Ok(Word { Ok(Word {
id: row.get(0)?, id: row.get(0)?,
@ -49,4 +59,16 @@ impl Database {
})? })?
.collect::<Result<Vec<Word>>>() .collect::<Result<Vec<Word>>>()
} }
pub fn add_word(&mut self, word: &Word) -> Result<()> {
self.conn.execute(
"INSERT INTO word (word, reading) VALUES (?1, ?2)",
params![
word.word,
word.reading,
],
)?;
self.last_word_id += 1;
Ok(())
}
} }

View file

@ -12,7 +12,7 @@ pub use word::*;
fn main() { fn main() {
const PORT: u16 = 8080; const PORT: u16 = 8080;
let mut server = Server::new(PORT) let mut server = Server::new(PORT, true)
.unwrap_or_else(|_| panic!("Failed to start server at port {PORT}")); .unwrap_or_else(|error| panic!("Failed to start server at port {PORT}: {:?}", error));
server.run(); server.run();
} }

View file

@ -1,9 +1,13 @@
use crate::dictionary::lookup; use crate::DatabaseCreationError;
use crate::lookup;
use crate::Word;
use crate::Database;
use simple_websockets::{Event, Responder, Message, EventHub}; use simple_websockets::{Event, Responder, Message, EventHub};
use std::collections::HashMap; use std::collections::HashMap;
use serde::Serialize; use serde::Serialize;
use wana_kana::{ConvertJapanese, IsJapaneseStr}; use wana_kana::{ConvertJapanese, IsJapaneseStr};
use derive_more::From;
#[derive(Serialize)] #[derive(Serialize)]
struct MessageResponse { struct MessageResponse {
@ -25,9 +29,11 @@ enum MessageResponseData {
}, },
Word { Word {
author: u64, author: u64,
word: String, word: Word,
reading: Option<String>, next_mora: String,
next_char: char, },
History {
words: Vec<Word>,
}, },
PlayerCount { PlayerCount {
players: u64, players: u64,
@ -42,6 +48,7 @@ impl MessageResponseData {
String::from(match self { String::from(match self {
Self::Greeting { .. } => "greeting", Self::Greeting { .. } => "greeting",
Self::Word { .. } => "word", Self::Word { .. } => "word",
Self::History { .. } => "history",
Self::PlayerCount { .. } => "playerCount", Self::PlayerCount { .. } => "playerCount",
Self::Error { .. } => "error", Self::Error { .. } => "error",
}) })
@ -61,29 +68,89 @@ impl MessageResponseData {
pub struct Server { pub struct Server {
event_hub: EventHub, event_hub: EventHub,
database: Database,
clients: HashMap<u64, Responder>, clients: HashMap<u64, Responder>,
next_char: Option<char>, next_mora: Option<String>,
last_response: Option<MessageResponse>,
last_client_id: Option<u64>, last_client_id: Option<u64>,
} }
#[derive(From, Debug)]
pub enum ServerCreationError {
SimpleWebsocketsError(simple_websockets::Error),
DatabaseCreationError(DatabaseCreationError),
}
#[derive(From, Debug)]
pub enum ServerError {
DatabaseError(rusqlite::Error),
}
fn get_starting_mora(word: &str) -> String {
if word.is_empty() {
return String::from("");
}
let word = word.to_hiragana();
let mut iter = word.chars();
let starting_char = iter.next().unwrap();
let second_char = iter.next().unwrap();
match second_char {
'ゃ' | 'ゅ' | 'ょ' => format!("{starting_char}{second_char}"),
_ => starting_char.to_string(),
}
}
// Trim off lengtheners and non-kana that are irrelevant to shiritori
// TODO: Use slices
fn trim_irrelevant_chars(word: &str) -> String {
let mut iter = word.chars().rev().peekable();
while let Some(c) = iter.peek() {
if *c == 'ー' || !c.to_string().is_kana() {
iter.next();
} else {
break;
}
}
iter.rev().collect()
}
// Get final mora, which could be multiple chars e.g. しゃ
// TODO: Use slices
fn get_final_mora(word: &str) -> String {
let word = trim_irrelevant_chars(word).to_hiragana();
if word.is_empty() {
return String::from("");
}
let mut iter = word.chars().rev();
let final_char = iter.next().unwrap();
match final_char {
'ゃ' | 'ゅ' | 'ょ' => format!("{}{final_char}", match iter.next() {
Some(c) => c.to_string(),
None => String::from(""),
}),
_ => final_char.to_string(),
}
}
impl Server { impl Server {
pub fn new(port: u16) -> Result<Self, simple_websockets::Error> { pub fn new(port: u16, testing: bool) -> Result<Self, ServerCreationError> {
Ok(Server { Ok(Server {
event_hub: simple_websockets::launch(port)?, event_hub: simple_websockets::launch(port)?,
database: Database::new(testing)?,
clients: HashMap::new(), clients: HashMap::new(),
next_char: None, next_mora: None,
last_response: None,
last_client_id: None, last_client_id: None,
}) })
} }
pub fn run(&mut self) { pub fn run(&mut self) {
loop { loop {
match self.event_hub.poll_event() { match match self.event_hub.poll_event() {
Event::Connect(client_id, responder) => self.handle_connection(client_id, responder), Event::Connect(client_id, responder) => self.handle_connection(client_id, responder),
Event::Disconnect(client_id) => self.handle_disconnection(client_id), Event::Disconnect(client_id) => self.handle_disconnection(client_id),
Event::Message(client_id, message) => self.handle_message(client_id, message), Event::Message(client_id, message) => self.handle_message(client_id, message),
} {
Ok(()) => {},
Err(error) => println!("{:?}", error),
} }
} }
} }
@ -95,27 +162,31 @@ impl Server {
} }
} }
fn handle_connection(&mut self, client_id: u64, responder: Responder) { fn handle_connection(&mut self, client_id: u64, responder: Responder) -> Result<(), ServerError> {
println!("A client connected with id #{}", client_id); println!("A client connected with id #{}", client_id);
responder.send(MessageResponseData::Greeting { id: client_id }.into_message()); responder.send(MessageResponseData::Greeting {
if let Some(ref last_response) = self.last_response { id: client_id
responder.send(last_response.to_message()); }.into_message());
} responder.send(MessageResponseData::History {
words: self.database.load_words_before(self.database.last_word_id + 1)?
}.into_message());
self.clients.insert(client_id, responder); self.clients.insert(client_id, responder);
self.broadcast_player_count(); self.broadcast_player_count();
Ok(())
} }
fn handle_disconnection(&mut self, client_id: u64) { fn handle_disconnection(&mut self, client_id: u64) -> Result<(), ServerError> {
println!("Client #{} disconnected.", client_id); println!("Client #{} disconnected.", client_id);
self.clients.remove(&client_id); self.clients.remove(&client_id);
self.broadcast_player_count(); self.broadcast_player_count();
Ok(())
} }
fn handle_message(&mut self, client_id: u64, message: Message) { fn handle_message(&mut self, client_id: u64, message: Message) -> Result<(), ServerError> {
// Ignore binary messages // Ignore binary messages
let message = match message { let message = match message {
Message::Text(message) => message, Message::Text(message) => message,
Message::Binary(_) => return, Message::Binary(_) => return Ok(()),
}; };
// Debug // Debug
@ -128,31 +199,23 @@ impl Server {
} else { } else {
match lookup(&message) { match lookup(&message) {
Some(entry) => { Some(entry) => {
println!("{}, {:?}", get_starting_mora(&entry.reading), self.next_mora);
if entry.reading.chars().last().unwrap().to_string().to_hiragana() == "" { if entry.reading.chars().last().unwrap().to_string().to_hiragana() == "" {
MessageResponseData::Error { MessageResponseData::Error {
message: String::from("Can't end with ん!"), message: String::from("Can't end with ん!"),
} }
} else if self.next_char.is_none() || entry.reading.chars().next().unwrap().to_string().to_hiragana().chars().next().unwrap() == self.next_char.unwrap() { } else if self.next_mora.is_none() || get_starting_mora(&entry.reading).eq(self.next_mora.as_deref().unwrap()) {
self.next_char = { let word: Word = entry.clone().into();
// If final character is lengthener or not kana self.next_mora = Some(get_final_mora(&word.reading));
// Use semifinal self.database.add_word(&word)?;
let mut final_chars = entry.reading.chars().rev();
let final_char = final_chars.next().unwrap();
Some(if final_char == 'ー' || !final_char.to_string().is_kana() {
final_chars.next().unwrap()
} else {
final_char
}.to_string().to_hiragana().chars().next().unwrap())
};
MessageResponseData::Word { MessageResponseData::Word {
author: client_id, author: client_id,
word: entry.kanji.to_owned(), word,
reading: Some(entry.reading.to_owned()), next_mora: self.next_mora.as_deref().unwrap().to_owned(),
next_char: self.next_char.unwrap(),
} }
} else { } else {
MessageResponseData::Error { MessageResponseData::Error {
message: String::from("Wrong starting kana!"), message: String::from("Wrong starting mora!"),
} }
} }
} }
@ -172,10 +235,9 @@ impl Server {
for (_client, responder) in self.clients.iter() { for (_client, responder) in self.clients.iter() {
responder.send(response.to_message()); responder.send(response.to_message());
} }
self.last_response = Some(response);
self.last_client_id = Some(client_id); self.last_client_id = Some(client_id);
}, },
} };
Ok(())
} }
} }

View file

@ -1,11 +1,26 @@
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde::Serialize; use serde::Serialize;
use jisho::Entry;
use std::convert::From;
#[derive(Serialize)] #[derive(Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct Word { pub struct Word {
pub id: i64, #[serde(skip_serializing)]
pub id: Option<i64>,
pub word: String, pub word: String,
pub reading: String, pub reading: String,
pub timestamp: DateTime<Utc>, #[serde(skip_serializing_if = "Option::is_none")]
pub timestamp: Option<DateTime<Utc>>,
}
impl From<Entry> for Word {
fn from(entry: Entry) -> Self {
Self {
id: None,
word: entry.kanji,
reading: entry.reading,
timestamp: None,
}
}
} }