【Rust】フレームワーク自作: セッションのレコードを自動削除

$ psql –version
psql (PostgreSQL) 14.15 (Ubuntu 14.15-0ubuntu0.22.04.1)
$ sudo apt install postgresql-14-cron
$ sudo vi /etc/postgresql/14/main/postgresql.conf

以下を追加
shared_preload_libraries = ‘pg_cron’
cron.database_name = ‘postgres’

$ sudo systemctl restart postgresql

$ CREATE EXTENSION IF NOT EXISTS pg_cron;

一時間おきに1日前に作成されたセッションを削除する設定にする
$ SELECT cron.schedule(‘delete_expired_sessions’, ‘0 * * * *’, $$
DELETE FROM ares_sessions
WHERE created_at < NOW() - INTERVAL '1 day' $$); cronではなく、psql自体にバッチ処理の仕組みが備わっているのは素晴らしいね。

【Rust】フレームワーク自作: jsonも扱えるようにする

let content_type = request
        .lines()
        .find(|line| line.to_ascii_lowercase().starts_with("content-type:"))
        .unwrap_or("");
    let is_json = content_type.contains("application/json");
//

pub fn parse_json(body: &str) -> Option<HashMap<String, String>> {
    let parsed: Result<Value, _> = serde_json::from_str(body);
    match parsed {
        Ok(Value::Object(map)) => {
            let mut result = HashMap::new();
            for (k, v) in map {
                if let Some(s) = v.as_str() {
                    result.insert(k, s.to_string());
                } else {
                    result.insert(k, v.to_string()); // fallback
                }
            }
            Some(result)
        }
        _ => None,
    }
}

jsonデータの場合は、parse_jsonとしてdataを利用する。

fn handle_api_post(_req: &str, body: String) -> HttpResponse {
    let data = parse_json(&body).unwrap_or_default();

    let binding = "<unknown>".to_string();
    let name = data.get("name").unwrap_or(&binding);
    let message = format!("{{\"greeting\": \"Hello, {}!\"}}", name);

    let mut response = HttpResponse::new(200, &message);
    response.headers.insert("Content-Type".to_string(), "application/json".to_string());
    response
}

なるほどー

【Rust】フレームワーク自作: ログイン・csrf認証をpostメソッドで行う

サインイン、ログイン時に、is_authenticatedをtrueに変更する設計にする。そうすることにより、ログイン前のログイン画面やサインイン画面で匿名セッションを発行して、csrf認証を行うことができる。

CREATE TABLE ares_sessions (
    id integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
    session_token TEXT,
    csrf_token TEXT,
    is_authenticated BOOLEAN DEFAULT FALSE,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
            let parsed_form = parse(&body);
            let csrf_token_from_form = parsed_form.get("csrf_token").map(|s| s.as_str());

            let csrf_valid = match (&session_token, csrf_token_from_form) {
                (Some(sess_token), Some(csrf_token)) => validate_csrf_token(sess_token, csrf_token),
                _ => false,
            };
//

pub fn validate_session_token(token: &str) -> bool {
    let mut client = match psql_connect() {
        Ok(c) => c,
        Err(_) => return false,
    };

    let rows = match client.query(
        "SELECT is_authenticated FROM ares_sessions WHERE session_token = $1",
        &[&token],
    ) {
        Ok(r) => r,
        Err(_) => return false,
    };

    if rows.is_empty() {
        return false;
    }

    let is_authenticated: bool = rows[0].get(0);
    is_authenticated
}

【Rust】フレームワーク自作: 静的ファイルハンドリング

静的ファイルはstaticに置くルールとする

    if path.starts_with("/static/") {
        let file_path = format!(".{}", path);

        match fs::read(&file_path) {
            Ok(contents) => {
                let content_type = get_content_type(path);
                let response = format!(
                    "HTTP/1.1 200 OK\r\nContent-Type: {}\r\nContent-Length: {}\r\n\r\n",
                    content_type,
                    contents.len()
                );
                let _ = stream.write_all(response.as_bytes());
                let _ = stream.write_all(&contents);
            }
            Err(_) => {
                let response = http_response(404, "text/plain", "Static file not found");
                let _ = stream.write_all(response.as_bytes());
            }
        }
        stream.flush().unwrap();
        return;
    }
// 

fn get_content_type(path: &str) -> &'static str {
    if path.ends_with(".css") {
        "text/css"
    } else if path.ends_with(".js") {
        "application/javascript"
    } else if path.ends_with(".png") {
        "image/png"
    } else if path.ends_with(".jpg") || path.ends_with(".jpeg") {
        "image/jpeg"
    } else if path.ends_with(".svg") {
        "image/svg+xml"
    } else if path.ends_with(".woff2") {
        "font/woff2"
    } else {
        "application/octet-stream"
    }
}

templatesフォルダのファイルが、以下対応できるようになります。

<link rel="stylesheet" href="/static/styles.css">
<script src="/static/app.js"></script>
<h1>Welcome to the Rust server!</h1>
<p>hello, {{ name }}</p>

【Rust】フレームワーク自作: 匿名セッションによるCSRF対策を実装したい

get methodもhashmapではなく、HttpResponseに変更することで、get methodでもcookieをセットできるようになる。

use std::collections::HashMap;
mod ares;
use ares::{Router, HttpResponse, parse, signup, logup, redirect, render_template_from_file, render_template_from_file_empty};

fn main() {
    let mut router = Router::new();

    router.get("index", handle_index);
    router.get("hello", handle_hello);
    router.post("test", handle_test);

    router.get("signin", handle_signin);
    router.post("signup", handle_signup);

    router.get("login", handle_login);
    router.post("logup", handle_logup);
    router.get("logout", handle_logout);

    let needs_auth = Some(vec!["/index", "/hello"]);
    router.up("192.168.33.10:8000", needs_auth);
}


fn handle_index() -> HttpResponse {
    let mut content = HashMap::new();
    content.insert(
        "title",
        "This is index page!",
    );
    let html = render_template_from_file("index", &content);
    HttpResponse::new(200, &html)
}

fn handle_hello() -> HttpResponse {
    let mut data = HashMap::new();
    data.insert("name", "taro");
    let html = render_template_from_file("hello", &data);
    HttpResponse::new(200, &html)
}

fn handle_signin() -> HttpResponse {
    let html = render_template_from_file_empty("signin");
    HttpResponse::new(200, &html)
}

fn handle_signup(body: String) -> HttpResponse {
    let form =  parse(&body);
    let binding = "<unknown>".to_string();
    let name = form.get("name").unwrap_or(&binding);
    let password = form.get("password").unwrap_or(&binding);
    let _csrf_token = form.get("csrf_token").unwrap_or(&"".to_string());
    let redirect_path = "/index";

    signup(name, password, redirect_path)
}

fn handle_login() -> HttpResponse {
    let html = render_template_from_file_empty("login");
    HttpResponse::new(200, &html)
}

fn handle_logup(body: String) -> HttpResponse {
    let form =  parse(&body);
    let binding = "<unknown>".to_string();
    let name = form.get("name").unwrap_or(&binding);
    let password = form.get("password").unwrap_or(&binding);

    let login_success_path = "/index";
    let login_failure_path = "/login";

    logup(name, password, login_success_path, login_failure_path)
}

fn handle_test(_body: String) -> HttpResponse {
    redirect("/index");
    HttpResponse::new(200, "<h1>This is Test!</h1>")
}

fn handle_logout() -> HttpResponse {
    let html = render_template_from_file_empty("logout");
    HttpResponse::new(200, &html)
}

以下のようにする

fn handle_login() -> HttpResponse {
    let (csrf_token, mut response) = create_csrf_token();
    let mut context = HashMap::new();
    context.insert("csrf_token", csrf_token);

    let html = render_template_from_file("login", &context);
    response.body = html;
    response
}
pub fn create_csrf_token() -> (&'static str, HttpResponse) {
    let (session_token, csrf_token) = create_session_token_and_csrf();
    if let Ok(mut client) = psql_connect() {
        let _ = client.execute(
            "INSERT INTO ares_sessions (session_token, csrf_token) VALUES ($1, $2)",
            &[&session_token, &csrf_token],
        );
    }
    let mut response = HttpResponse::new(200, "");
    response.headers.insert(
        "Set-Cookie".to_string(),
        format!("session_token={}; Path=/; HttpOnly", session_token),
    );
    let csrf_token_leaked = Box::leak(csrf_token.into_boxed_str());
    (csrf_token_leaked, response)
}

CSRFとセッションのチェックはライブラリ側で行いたい

【Rust】フレームワーク自作: セッション管理はフレームワーク側で実行する

ユーザ側は必要最低限の実装で済むようにする

use std::collections::HashMap;
mod ares;
use ares::{Router, HttpResponse, parse, signup, logup, redirect};

fn main() {
    let mut router = Router::new();

    router.get("index", handle_index);
    router.get("hello", handle_hello);
    router.get("signin", handle_signin);
    router.post("signup", handle_signup);
    router.get("login", handle_login);
    router.post("logup", handle_logup);
    router.post("test", handle_test);

    let needs_auth = Some(vec!["/index", "/hello"]);
    router.up("192.168.33.10:8000", needs_auth);
}


fn handle_index() -> Option<HashMap<&'static str, &'static str>> {

    let mut content1 = HashMap::new();
    content1.insert(
        "title",
        "This is index page!",
    );
    return Some(content1);
}

fn handle_hello() -> Option<HashMap<&'static str, &'static str>> {
    return None;
}

fn handle_signin() -> Option<HashMap<&'static str, &'static str>> {
    return None;
}

fn handle_signup(body: String) -> HttpResponse {
    let form =  parse(&body);
    let binding = "<unknown>".to_string();
    let name = form.get("name").unwrap_or(&binding);
    let password = form.get("password").unwrap_or(&binding);
    let redirect_path = "/index";

    signup(name, password, redirect_path)
}

fn handle_login() -> Option<HashMap<&'static str, &'static str>> {
    return None;
}

fn handle_logup(body: String) -> HttpResponse {
    let form =  parse(&body);
    let binding = "<unknown>".to_string();
    let name = form.get("name").unwrap_or(&binding);
    let password = form.get("password").unwrap_or(&binding);

    let login_success_path = "/index";
    let login_failure_path = "/login";

    logup(name, password, login_success_path, login_failure_path)
}

fn handle_test(_body: String) -> HttpResponse {
    redirect("/index");
    HttpResponse::new(200, "<h1>This is Test!</h1>")
}

大分簡素になりました。

【Rust】フレームワーク自作: セッションが無ければリダイレクト処理

fn handle_connection(mut stream: TcpStream, get_routes: HashMap<String, String>, post_routes: HashMap<String, fn(String) -> HttpResponse>)  {
    let mut buffer = [0; 1024];
    stream.read(&mut buffer).unwrap();
 
    let request = String::from_utf8_lossy(&buffer);
    let request_line = request.lines().next().unwrap_or("");
    let mut parts = request_line.split_whitespace();
    let method = parts.next().unwrap_or("");
    let path = parts.next().unwrap_or("/");

    // println!("Received {} request for {}", method, path);

    let needs_auth = ["/index", "/hello"];
    let is_protected = needs_auth.contains(&path);

    let session_token = extract_cookie_token(&request);

    if is_protected && session_token.is_none() {
        let redirect_response = http_response_custom(redirect("/login"));
        stream.write_all(redirect_response.as_bytes()).unwrap();
        stream.flush().unwrap();
        return;
    }
 

    let response = match method {
        "GET" => {
            let body = get_routes.get(path).cloned().unwrap_or_else(|| "<h1>404 Not Found</h1>".to_string());
            http_response(200, "text/html", &body)
        }
        "POST" => {
            let body = request.split("\r\n\r\n").nth(1).unwrap_or("").to_string();
            match post_routes.get(path) {
                Some(handler) => {
                    let response = handler(body);
                    http_response_custom(response)
                },
                None => http_response(404, "text/html", "<h1>404 Not Found</h1>"),
            }
        }
        _ => http_response(405, "text/html", "<h1>405 Method Not Allowed</h1>"),
    };
 
    stream.write_all(response.as_bytes()).unwrap();
    stream.flush().unwrap();
 }

main.rs

use std::collections::HashMap;
use rand::SeedableRng; // トレイトは rand にある
use rand::rngs::OsRng;
use rand::RngCore;
use rand_chacha::ChaCha8Rng;
use pwhash::bcrypt;
mod ares;
use ares::{Router, HttpResponse, redirect, psql_connect, parse, remove_null_bytes, create_session_token};

fn main() {
    let mut router = Router::new();

    router.get("index", handle_index);
    router.get("hello", handle_hello);
    router.get("signin", handle_signin);
    router.post("signup", handle_signup);
    router.get("login", handle_login);
    router.post("logup", handle_logup);

    router.up("192.168.33.10:8000");
}


fn handle_index() -> Option<HashMap<&'static str, &'static str>> {

    let mut content1 = HashMap::new();
    content1.insert(
        "title",
        "This is index page!",
    );
    return Some(content1);
}

fn handle_hello() -> Option<HashMap<&'static str, &'static str>> {
    return None;
}

fn handle_signin() -> Option<HashMap<&'static str, &'static str>> {
    return None;
}

fn handle_signup(body: String) -> HttpResponse {
    let form =  parse(&body);
    let binding = "<unknown>".to_string();
    let name = form.get("name").unwrap_or(&binding);
    let password = form.get("password").unwrap_or(&binding);

    let pass_hash = bcrypt::hash(password).unwrap();

    let mut client = psql_connect().unwrap();
    let _ = client.execute(
        "INSERT INTO test (username, password) VALUES ($1, $2)",
        &[&remove_null_bytes(&name), &remove_null_bytes(&pass_hash)],
    ).unwrap();
    HttpResponse::new(200, "<h1>Success Signin!</h1>")
}

fn handle_login() -> Option<HashMap<&'static str, &'static str>> {
    return None;
}

fn handle_logup(body: String) -> HttpResponse {
    let form =  parse(&body);
    let binding = "<unknown>".to_string();
    let name = form.get("name").unwrap_or(&binding);
    let password = form.get("password").unwrap_or(&binding);

    let mut client = psql_connect().unwrap();
    let row = client.query(
        "SELECT * from test where username=$1",
        &[&name],
    ).unwrap();
    if row.is_empty() {
        return redirect("/login");
    }
    let value: String = row[0].get(2);

    if bcrypt::verify(password, &value) {
        let session_token = create_session_token();
        println!("{}", session_token);
        let mut response = HttpResponse::new(200, "<h1>Success Login!</h1>");
        response.headers.insert(
            "Set-Cookie".to_string(),
            format!("session_token={}; Path=/; HttpOnly", session_token),
        );
        response
    } else {
        redirect("/login")
    }
}

– どのページをログイン必須にするかは、ユーザ側で決めたい
– cookieのセットはフレームワーク側で行いたい

【Rust】フレームワーク自作: HttpResponse, Redirectを実装する

利用側: main.rs

use std::collections::HashMap;
use pwhash::bcrypt;
mod ares;
use ares::{Router, HttpResponse, redirect, psql_connect, parse, remove_null_bytes};

fn main() {
    let mut router = Router::new();

    router.get("index", handle_index);
    router.get("hello", handle_hello);
    router.get("signin", handle_signin);
    router.post("signup", handle_signup);
    router.get("login", handle_login);
    router.post("logup", handle_logup);

    router.up("192.168.33.10:8000");
}


fn handle_index() -> Option<HashMap<&'static str, &'static str>> {

    let mut content1 = HashMap::new();
    content1.insert(
        "title",
        "This is index page!",
    );
    return Some(content1);
}

fn handle_hello() -> Option<HashMap<&'static str, &'static str>> {
    return None;
}

fn handle_signin() -> Option<HashMap<&'static str, &'static str>> {
    return None;
}

fn handle_signup(body: String) -> HttpResponse {
    let form =  parse(&body);
    let binding = "<unknown>".to_string();
    let name = form.get("name").unwrap_or(&binding);
    let password = form.get("password").unwrap_or(&binding);

    let pass_hash = bcrypt::hash(password).unwrap();

    let mut client = psql_connect().unwrap();
    let _ = client.execute(
        "INSERT INTO test (username, password) VALUES ($1, $2)",
        &[&remove_null_bytes(&name), &remove_null_bytes(&pass_hash)],
    ).unwrap();
    HttpResponse::new(200, "<h1>Success Signin!</h1>")
}

fn handle_login() -> Option<HashMap<&'static str, &'static str>> {
    return None;
}

fn handle_logup(body: String) -> HttpResponse {
    let form =  parse(&body);
    let binding = "<unknown>".to_string();
    let name = form.get("name").unwrap_or(&binding);
    let password = form.get("password").unwrap_or(&binding);

    let mut client = psql_connect().unwrap();
    let row = client.query(
        "SELECT * from test where username=$1",
        &[&name],
    ).unwrap();
    let value: String = row[0].get(2);
    if bcrypt::verify(password, &value) {
        HttpResponse::new(200, "<h1>Success Login!</h1>")
    } else {
        redirect("/login")
    }
}

フレームワーク側

use std::fs;
use std::io::prelude::*;
use std::net::{TcpListener, TcpStream};
use std::collections::HashMap;
use postgres::{Client, NoTls};
use std::env;
use dotenv::dotenv;

pub struct HttpResponse {
    pub status_code: u16,
    pub headers: HashMap<String, String>,
    pub body: String,
}

impl HttpResponse {
    pub fn new(status_code: u16, body: &str) -> Self {
        let mut headers = HashMap::new();
        headers.insert("Content-Type".to_string(), "text/html".to_string());

        HttpResponse {
            status_code,
            headers,
            body: body.to_string(),
        }
    }
}

pub struct Router {
    get_routes: HashMap<String, String>,
    post_routes: HashMap<String, fn(String) -> HttpResponse>,
}

impl Router {

    pub fn new() -> Self {
        Router {
            get_routes: HashMap::new(),
            post_routes: HashMap::new(),
        }
    }

    pub fn get(&mut self, path: &str, f: fn()-> Option<HashMap<&'static str, &'static str>>){
        // let mut content: Option<HashMap<&str, &str>> = Some(HashMap::new());
        let content = f();

        let temp_path = format!("./templates/{}.html", path);
        let mut html = fs::read_to_string(temp_path).unwrap();

        match content {
            Some(data) => {
                for (key, value) in data {
                    let k = format!("{{{{ {} }}}}", key);
                    html = html.replace(&k, value);
                }
            },
            None => {},
        }
        let route_path = format!("/{}", path);
        self.get_routes.insert(route_path, html);
    }

    pub fn post(&mut self, path: &str, handler: fn(String) -> HttpResponse) {
        self.post_routes.insert(format!("/{}", path), handler);
    }

    pub fn up(&self, ip: &str) {
        let listenr = TcpListener::bind(ip).unwrap();
        for stream in listenr.incoming() {
            match stream {
                Ok(stream) => {
                    let _ = handle_connection(
                        stream, 
                        self.get_routes.clone(),
                        self.post_routes.clone(),
                    );
                }
                Err(e) => {
                    println!("Connection failed: {}", e);
                }
            }
        }
    }
}

fn handle_connection(mut stream: TcpStream, get_routes: HashMap<String, String>, post_routes: HashMap<String, fn(String) -> HttpResponse>)  {
    let mut buffer = [0; 1024];
    stream.read(&mut buffer).unwrap();
 
    let request = String::from_utf8_lossy(&buffer);
    let request_line = request.lines().next().unwrap_or("");
    let mut parts = request_line.split_whitespace();
    let method = parts.next().unwrap_or("");
    let path = parts.next().unwrap_or("/");

    // println!("Received {} request for {}", method, path);
 

    let response = match method {
        "GET" => {
            let body = get_routes.get(path).cloned().unwrap_or_else(|| "<h1>404 Not Found</h1>".to_string());
            http_response(200, "text/html", &body)
        }
        "POST" => {
            let body = request.split("\r\n\r\n").nth(1).unwrap_or("").to_string();
            match post_routes.get(path) {
                Some(handler) => {
                    let response = handler(body);
                    http_response_custom(response)
                },
                None => http_response(404, "text/html", "<h1>404 Not Found</h1>"),
            }
        }
        _ => http_response(405, "text/html", "<h1>405 Method Not Allowed</h1>"),
    };
 
    stream.write_all(response.as_bytes()).unwrap();
    stream.flush().unwrap();
 }
 
 fn http_response(status_code: u16, content_type: &str, body: &str) -> String {
    format!(
        "HTTP/1.1 {} {}\r\nContent-Type: {}\r\nContent-Length: {}\r\n\r\n{}",
        status_code,
        get_status_text(status_code),
        content_type,
        body.len(),
        body
    )
 }
 
 fn http_response_custom(resp: HttpResponse) -> String {
    let mut response = format!(
        "HTTP/1.1 {} {}\r\n",
        resp.status_code,
        get_status_text(resp.status_code)
    );

    let mut has_location = false;
    for (k, v) in &resp.headers {
        if k.to_lowercase() == "location" {
            has_location = true;
        }
        response.push_str(&format!("{}: {}\r\n", k, v));
    }

    if !has_location {
        response.push_str("Content-Type: text/html\r\n");
    }

    response.push_str(&format!("Content-Length: {}\r\n", resp.body.len()));
    response.push_str("\r\n"); 

    response.push_str(&resp.body);

    response
}


 fn get_status_text(code: u16) -> &'static str {
    match code {
        200 => "OK",
        404 => "Not Found",
        _ => "Unknown",
    }
 }

pub fn parse(body: &str) -> HashMap<String, String> {
    let mut data = HashMap::new();

    for pair in body.split('&') {
        let mut iter = pair.splitn(2, '=');
        if let (Some(key), Some(value)) = (iter.next(), iter.next()) {
            let key = url_decode(key);
            let value = url_decode(value);
            data.insert(key, value);
        }
    }
    data
}

fn url_decode(s: &str) -> String {
    s.replace("+", " ")
        .replace("%20", " ") 
}

pub fn psql_connect() -> Result<Client, Box<dyn std::error::Error>> {
    let _ = dotenv();
    let conn_str = format!("host=localhost user=postgres password={}", env::var("PSQL_PASSWORD").unwrap());
    let client = Client::connect(&conn_str, NoTls)?;

    Ok(client)
}

pub fn redirect(location: &str) -> HttpResponse {
    let mut headers = HashMap::new();
    headers.insert("Location".to_string(), location.to_string());

    HttpResponse {
        status_code: 302,
        headers,
        body: String::new(), // もしくは軽いメッセージを入れてもOK
    }
}

pub fn remove_null_bytes(s: &str) -> String {
    s.chars().filter(|&c| c != '\0').collect()
}

これに、cookieの機能をつけたい

サッカーゲームをRustで書く

サッカーゲームというより、単なるPK戦だなぁ〜
もう少しリアリティのあるゲームにしたい…

use std::io;
use rand::Rng;

struct Game {
   player_score: i32,
   cpu_score: i32,
}

impl Game {

   fn new() -> Self {
      Game {
         player_score: 0,
         cpu_score: 0,
      }
   }

   fn player_attack() -> i32 {
      println!("攻撃! シュートする方向を選んでください:");
      println!("1: 左\n2: 中央\n3: 右");
      let mut input = String::new();
      io::stdin()
         .read_line(&mut input)
         .expect("入力の読み取りに失敗しました");
      let dir: i32 = match input.trim().parse() {
         Ok(num) => num,
         Err(_) => {
            println!("無効な入力です。数字を入力してください。");
            return 0;
         }
      };
      return dir;
   }

   fn cpu_defend() -> i32 {
      let mut rng = rand::thread_rng();
      return rng.gen_range(1..=3);
   }

   fn cpu_attack() -> i32 {
      let mut rng = rand::thread_rng();
      return rng.gen_range(1..=3);
   }

   fn player_defend() -> i32 {
      println!("守備!相手のシュートを読む方向を選んでください:");
      println!("1: 左\n2: 中央\n3: 右");
      let mut input = String::new();
      io::stdin()
         .read_line(&mut input)
         .expect("入力の読み取りに失敗しました");
      let dir: i32 = match input.trim().parse() {
         Ok(num) => num,
         Err(_) => {
            println!("無効な入力です。数字を入力してください。");
            return 0;
         }
      };
      return dir;
   }
}

fn main() {
   let mut game1 = Game::new();
   println!("{}", game1.player_score);

   for turn in 1..4 {
      println!("--- 第{}ターン ---", turn);
      let player_dir = Game::player_attack();
      let cpu_dir = Game::cpu_defend();

      if player_dir != cpu_dir {
         println!("ゴール!! 🎉");
         game1.player_score += 1;
      } else {
         println!("セーブされました...🧤");
      }

      let cpu_dir = Game::cpu_attack();
      let player_dir = Game::player_defend();

      if player_dir != cpu_dir {
         println!("ゴール!! 😱");
         game1.cpu_score += 1;
      } else {
         println!("ナイスセーブ!! 🧤");
      }
   }

   println!("=== 試合終了 ===");

   println!("あなた: {}, CPU: {}", game1.player_score, game1.cpu_score);
}

— 第1ターン —
攻撃! シュートする方向を選んでください:
1: 左
2: 中央
3: 右
1
ゴール!! 🎉
守備!相手のシュートを読む方向を選んでください:
1: 左
2: 中央
3: 右
2
ゴール!! 😱
— 第2ターン —
攻撃! シュートする方向を選んでください:
1: 左
2: 中央
3: 右
1
ゴール!! 🎉
守備!相手のシュートを読む方向を選んでください:
1: 左
2: 中央
3: 右
1
ゴール!! 😱
— 第3ターン —
攻撃! シュートする方向を選んでください:
1: 左
2: 中央
3: 右
1
セーブされました…🧤
守備!相手のシュートを読む方向を選んでください:
1: 左
2: 中央
3: 右
1
ゴール!! 😱
=== 試合終了 ===
あなた: 2, CPU: 3

サッカーゲーム

import random

def player_attack():
    print("攻撃!シュートする方向を選んでください:")
    print("1: 左\n2: 中央\n3: 右")
    try:
        choice = int(input("あなたの選択: "))
        if choice not in [1, 2, 3]:
            raise ValueError
    except ValueError:
        print("無効な入力です。中央にします。")
        choice = 2
    return choice

def cpu_defend():
    return random.randint(1, 3)

def cpu_attack():
    return random.randint(1, 3)

def player_defend():
    print("守備!相手のシュートを読む方向を選択してください:")
    print("1: 左\n2: 中央\n3: 右")
    try:
        choice = int(input("あなたの選択: "))
        if choice not in [1, 2, 3]:
            raise ValueError
    except ValueError:
        print("無効な入力です。中央にします。")
        choice = 2
    return choice

def game():
    player_score = 0
    cpu_score = 0

    print("== サッカー対決: 3ターンマッチ ==")
    for turn in range(1, 4):
        print(f"\n--- 第{turn}ターン ---")

        attack_dir = player_attack()
        defend_dir = cpu_defend()
        if attack_dir != defend_dir:
            print("ゴール!! 🎉")
            player_score += 1
        else:
            print("セーブされた!")

        attack_dir = cpu_attack()
        defend_dir = player_defend()
        if attack_dir != defend_dir:
            print("CPUがゴール!! 😱")
            cpu_score += 1
        else:
            print("ナイスセーブ!! 🎉")

    print(f"\n== 試合終了==\n あなた: {player_score}点\n CPU:{cpu_score}点")
    if player_score > cpu_score:
        print("勝利")
    elif player_score < cpu_score:
        print("敗北")
    else:
        print("引き分け")

if __name__ == "__main__":
    game()

$ python3 football.py
== サッカー対決: 3ターンマッチ ==

— 第1ターン —
攻撃!シュートする方向を選んでください:
1: 左
2: 中央
3: 右
あなたの選択: 1
ゴール!! 🎉
守備!相手のシュートを読む方向を選択してください:
1: 左
2: 中央
3: 右
あなたの選択: 2
CPUがゴール!! 😱

— 第2ターン —
攻撃!シュートする方向を選んでください:
1: 左
2: 中央
3: 右
あなたの選択: 1
ゴール!! 🎉
守備!相手のシュートを読む方向を選択してください:
1: 左
2: 中央
3: 右
あなたの選択: 3
ナイスセーブ!! 🎉

— 第3ターン —
攻撃!シュートする方向を選んでください:
1: 左
2: 中央
3: 右
あなたの選択: 2
セーブされた!
守備!相手のシュートを読む方向を選択してください:
1: 左
2: 中央
3: 右
あなたの選択: 1
ナイスセーブ!! 🎉

== 試合終了==
あなた: 2点
CPU:1点
勝利