複数ラウンドで正しいスコアカードを管理する

RustBeginner
オンラインで実践に進む

はじめに

1つの得点は1回の判断にすぎません。完全なゲームでは使用済みのカテゴリを覚え、無効な選択を直す間も同じ出目を保つ必要があります。3ラウンドのゲームを完成させ、記録された出目とカテゴリから得点を再計算して検証できるスコアカードを保存します。

この独立したプロジェクトには、最初の Lab の採点ルールがすでに入っています。そのルール、Rust の関数、enum、match、Option、配列、基本的なターミナルのパス操作を理解していることを前提とします。準備済みの構造体、可変のメソッド、Result、スライスは、使用する際に説明します。読解と通常の入力ミスへの対応を含め、所要時間は約55分を見込んでいます。これは著者による見積もりです。

合法な選択だけを確定する

このステップでは、受理される1回の選択に対する状態遷移を設計・実装します。Terminal タブから始めます。この独立したプロジェクトには完成した採点ルールがあり、最初のラボのファイルは使いません。シェルの検索パスを設定してプロジェクトへ入り、新しいシェルではこれらのコマンドを繰り返します。

export PATH=/home/labex/.cargo/bin:$PATH
cd /home/labex/project/dice-game

まず正常に動く採点を、実行前に予想してから試します。

cargo run --quiet -- score pair 2 2 4 4 6

表示された出目と pair: 8 が得られるはずです。ゲーム状態を追加する前に、提供済みのルール層が使えると分かります。ひな形を読みます。

cat src/state.rs

struct は1つのオブジェクトを説明する名前付きフィールドをまとめます。Round は5個の出目、カテゴリ、得点を記録します。Game は current: Option<Dice> と rounds: Vec<Round> を持ちます。Some(dice) は選択が保留中、None は現在の出目がないことを意味します。Vec<Round> は拡張可能なコレクションで、Vec::new() は空のものを作り、push は要素を追加します。

準備済み入力プログラムの呼び出し側も読みます。

cat src/main.rs

match game.submit(input.trim()) を探します。Err 分岐は拒否を表示します。Ok 分岐は得点を表示し、記録が3ラウンド未満の場合だけ次の出目を取得します。この入力プログラムは編集しません。メソッドは拒否時に状態を変えず、次のプロンプトが同じ出目とサイコロを振った回数を表示する必要があります。

各種類の確定後にフィールドがどうなるか予想します。

確定内容 呼び出し後の rounds 呼び出し後の current
保留中の出目なし、未知の単語、使用済みカテゴリ 変更なし 変更なし
ゼロ点を含む有効な未使用カテゴリ 完全な記録を1件追加 None

成功後の次の出目は submit ではなく入力プログラムが提供します。メソッドでサイコロを振らないでください。impl Game は Game のメソッドをまとめます。&mut self により、可変参照を通してそのゲームを更新できます。Result<u16, &'static str> は Ok(points) または Err(message) を意味します。&'static str は固定エラーメッセージのテキスト型です。Option と違い、Result は失敗理由を説明できます。

ok_or(message) は Some を Ok、None を Err に変えます。続く ? は成功値で処理を続けるか、エラーを直ちに返します。たとえば保留中の出目の確認は次のように書けます。

let dice = self.current.ok_or("match finished")?;

Category::parse(input) にも同じ考え方を適用します。フィールドを変える前に、&self.rounds の for ループで以前の記録を借用し、一致するカテゴリを拒否します。チェックの組み立て方は自由ですが、表が契約です。検証後に score(dice, category) から points を求めます。成功時の確定は3つの動作です。

self.rounds.push(Round { dice, category, points });
self.current = None;
Ok(points)

短いフィールド名は同名のローカル変数を使います。ゼロという結果も成功です。拒否経路でこれらの変更を行ってはいけません。

src/main.rs、Cargo.toml、公開型、関数名と型、モジュール公開設定は維持します。use crate::{score, Category, Dice} は現在のライブラリから取り込み、src/lib.rs の mod state と pub use はモジュールをつなぎます。このステップは src/state.rs の submit だけを編集し、構造体、new、未完成の replay は残します。

編集前に許可されたこのファイルの一意のバックアップを作ります。既習の mktemp -d が6個の X を一意の接尾辞に置き換えます。$() は表示パスを保存し、&& はディレクトリ作成成功後だけ cp -p を実行します。引用したパスは1引数に保たれます。失敗したらエラーを読み、バックアップを作ってから編集します。

rs02_before_submit=$(mktemp -d .edit-before-submit.XXXXXX) && cp -p src/state.rs "$rs02_before_submit/state.rs"

nano で開きます。-c はカーソル位置を表示します。

nano -c src/state.rs

Ctrl+W で pub fn submit を検索して Enter を押します。メソッドの開始と終了の波括弧を確認します。未使用の _input 引数束縛を、型を変えず input に変えます。未完成の本体だけを、上のチェックと成功時の確定の実装に置き換えます。ほかのメソッドとデータ定義は残します。矢印キーと Backspace/Delete で編集し、Ctrl+O、Enter で保存して Ctrl+X で終了します。Nano の ^ は Ctrl です。未保存の誤編集は Ctrl+X と保存確認での N で破棄できますが、以前の保存は取り消されません。

実行前に保存したメソッドを読みます。

cat src/state.rs

既知の固定例で状態判断を観察します。--fixture は乱数源ではなく公開された出目を選びます。

cargo run --quiet -- play --fixture

各プロンプトを待って1単語を入力し、Enter を押します。

最初に pairs を入力します。未知なので、拒否後も同じ [2, 2, 4, 4, 6] と rolls 1 が出るはずです。 次に pair を入力します。8点で [1, 2, 3, 4, 6]、rolls 2 に進みます。 再び pair を入力します。使用済みカテゴリとして拒否し、出目も rolls 2 も変わりません。 次に straight を入力します。この有効な選択はゼロ点で、3回目の出目に進みます。 最後に sum を入力し、20点を得ます。

最後は Error: replay not implemented、ステータス1が想定どおりです。確定処理は動きましたが、replay は元の未完成関数なので、入力プログラムは未検証のカードの保存を拒否します。これは予定した中間状態で、保存に成功したゲームではありません。誤って別の合法なカテゴリを選んだら、未使用の選択肢で終えるか、Ctrl+C で止めて意図的に新しい固定例ゲームを始めます。1つのゲーム内で、拒否が再開始や振り直しを起こしてはいけません。

コンパイルエラーなら報告されたファイルと行を読み、nano を開き直して該当メソッドを探し、修正します。直せない保存済みの誤りなら、自分の submit 前のファイルを戻して実装・テストし直します。

保存済みの間違いから復旧するときだけ: 元の Terminal ではチェックポイント変数にパスが残っています。新しい Terminal では先に前述の PATH とプロジェクトディレクトリのコマンドを繰り返します。この段階のバックアップを列挙します。

ls -d .edit-before-submit.*

ls -d は内容ではなくディレクトリ名を表示し、* はこの段階の一意なディレクトリの接尾辞に一致します。実際に生成された名前なので、架空の接尾辞を入力しないでください。一致するバックアップがなければ、プロジェクトの場所と、このチェックポイントを作ったかを確認します。無関係な段階からコピーしてはいけません。表示されたパスを1つ選ぶには、次を実行します。

read rs02_before_submit

シェルは1行の入力を待ちます。選んだ正確なディレクトリ名を貼り付けて Enter を押します。read がその行をこのセッションの指定変数に保存します。復元前にバックアップ内容を確認します。

cat "$rs02_before_submit/state.rs"

submit 実装前の初期版が入っているはずです。 パスや段階が違えば、正しい表示済みパスで read を繰り返し、再確認します。その後だけ復元コマンドを使います。元の Terminal でも、変数がどのチェックポイントを指すか不確かなら、この確認を行います。

cp -p "$rs02_before_submit/state.rs" src/state.rs

このコマンドは意図的に編集可能な状態ファイルだけを置き換えます。バックアップ変数はこの Terminal のローカル変数なので、別のシェルでは条件付き復旧手順を使ってください。状態の誤りを直すために setup のリセットや準備済み入力プログラムの置き換えをしてはいけません。

完全なスコアカードを再計算して保存する

このステップでは、合計を受け入れる前に各記録ラウンドを独立して確認する処理を実装します。同じ Terminal の /home/labex/project/dice-game で続け、必要なら前の export PATH と cd を繰り返します。完成した submit は保ちます。

まず提案された固定例カードを手計算します。[2, 2, 4, 4, 6] の pair は8、[1, 2, 3, 4, 6] の straight は0、[6, 5, 4, 3, 2] の sum は20で、再計算した合計は28です。最初の行に8でなく10と書かれていたら、保存値を足すだけでは不正な記録を隠してしまいます。カード自身の保存合計や得点だけでは正しさを証明できません。

replay が受け取る &[Round] はスライス、つまり所有権の移動や変更を伴わない記録の借用ビューです。書く前にチェックを設計します。記録はちょうど3件必要です。採点実装が出目を配列の添字に使う可能性があるため、各記録では score の前に1〜6以外の出目を拒否します。使用済みカテゴリを小さな Vec で追跡し、contains で現在のカテゴリがすでに出たか確認して重複を拒否します。実際の出目とカテゴリから得点を再計算して保存値と比較します。再計算した結果だけを合計に加え、すべての記録が有効になって初めて合計を返します。

失敗条件には早期の return Err("...") を使えます。for round in rounds の各 round は借用され、round.dice は記録を変えず調べられる小さな Copy 配列です。!(1..=6).contains(&face) は両端を含む有効範囲の外を意味します。意味のある拒否メッセージを選んでください。契約を保つなら判断の構成は変えて構いません。

動作する submit を含む新しい取り消し用チェックポイントを保存します。前の初期版バックアップと混同してはいけません。

rs02_before_replay=$(mktemp -d .edit-before-replay.XXXXXX) && cp -p src/state.rs "$rs02_before_replay/state.rs"

同じファイルを開き、Ctrl+W で pub fn replay を検索します。

nano -c src/state.rs

未使用の _rounds 引数束縛を、型を保って rounds に変え、この関数の本体だけを実装します。submit、構造体、モジュール接続は保ちます。順序を考えたチェックと累積変数を使い、単に28を返したり、保存得点を信用したり、無効な記録を黙って飛ばしたりしないでください。Ctrl+O、Enter で保存し、Ctrl+X で終了して保存コードを読みます。

cat src/state.rs

固定例を再実行します。

cargo run --quiet -- play --fixture

各プロンプトを待ち、pair、straight、sum と入力します。最後の出力には次が含まれます。

1 | [2, 2, 4, 4, 6] | pair | 8
2 | [1, 2, 3, 4, 6] | straight | 0
3 | [6, 5, 4, 3, 2] | sum | 20
Replayed total: 28
Saved match.tsv

準備済み入力プログラムは、完全なカードが replay を通った後だけ match.tsv を書きます。各行は空白区切りの5個の出目、タブ、カテゴリ、タブ、得点です。この形式は準備済みプログラムが扱い、ファイルパーサーは実装しません。完了した新しいゲームはこのコース用カードを置き換え、未完了のゲームは以前のカードを保ちます。

実際の保存ファイルを読み、再計算します。

cat match.tsv
cargo run --quiet -- replay match.tsv

同じ記録と合計28で、Saved match.tsv はないはずです。Replay は正確な指定パスを変更せず読み、サンプルにフォールバックしません。submit に先ほどのカテゴリ順序を埋め込んでいないか確かめるため、固定例をもう一度遊びます。

cargo run --quiet -- play --fixture

straight、sum、pair を使います。0、16、0になると予想してください。3つとも合法で、再計算合計は16です。この完了ゲームが前のカードを置き換えます。現在のファイルを再計算します。

cargo run --quiet -- replay match.tsv

記録は前の28点ゲームではなく、この2番目の順序を反映しなければなりません。各出目で選ぶルールが変わったので、合計が異なるのは想定どおりです。次のステップでは同じ判断で実際のランダムな出目を使います。

コンパイルエラーなら指定ファイルを開き直し、示された replay の行を直します。必要なら、submit を保った自分の replay 前チェックポイントを戻し、replay を実装し直します。

保存済みの間違いから復旧するときだけ: 元の Terminal ではチェックポイント変数にパスが残っています。新しい Terminal では先に前述の PATH とプロジェクトディレクトリのコマンドを繰り返します。この段階のバックアップを列挙します。

ls -d .edit-before-replay.*

ls -d は内容ではなくディレクトリ名を表示し、* はこの段階の一意なディレクトリの接尾辞に一致します。実際に生成された名前なので、架空の接尾辞を入力しないでください。一致するバックアップがなければ、プロジェクトの場所と、このチェックポイントを作ったかを確認します。無関係な段階からコピーしてはいけません。表示されたパスを1つ選ぶには、次を実行します。

read rs02_before_replay

シェルは1行の入力を待ちます。選んだ正確なディレクトリ名を貼り付けて Enter を押します。read がその行をこのセッションの指定変数に保存します。復元前にバックアップ内容を確認します。

cat "$rs02_before_replay/state.rs"

replay 実装前の動作する submit が入っているはずです。 パスや段階が違えば、正しい表示済みパスで read を繰り返し、再確認します。その後だけ復元コマンドを使います。元の Terminal でも、変数がどのチェックポイントを指すか不確かなら、この確認を行います。

cp -p "$rs02_before_replay/state.rs" src/state.rs

ソースの復元は既存カードを変えません。後の完了ゲームは replay 成功後だけ置き換え、失敗したゲームやカードの読み取りでは変えません。下の完全な参考実装は、自分で試した後に特定の関数を比較するためのもので、通常の学習操作としてモジュールを置き換えるものではありません。

比較と復旧のための完全な状態の参考実装

フィールドと以前の成功した作業を保ちます。修復する関数だけを比較して nano で編集し、固定例を実行して実際のカードを再計算し直します。

use crate::{score, Category, Dice};

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Round {
    pub dice: Dice,
    pub category: Category,
    pub points: u16,
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Game {
    pub current: Option<Dice>,
    pub rounds: Vec<Round>,
}

impl Game {
    pub fn new(first: Dice) -> Self {
        Self { current: Some(first), rounds: Vec::new() }
    }

    pub fn submit(&mut self, input: &str) -> Result<u16, &'static str> {
        let dice = self.current.ok_or("match finished")?;
        let category = Category::parse(input).ok_or("unknown category")?;
        for round in &self.rounds {
            if round.category == category {
                return Err("category already used");
            }
        }
        let points = score(dice, category);
        self.rounds.push(Round { dice, category, points });
        self.current = None;
        Ok(points)
    }
}

pub fn replay(rounds: &[Round]) -> Result<u16, &'static str> {
    if rounds.len() != 3 {
        return Err("card needs three rounds");
    }
    let mut used = Vec::new();
    let mut total = 0;
    for round in rounds {
        for face in round.dice {
            if !(1..=6).contains(&face) {
                return Err("card has an invalid face");
            }
        }
        if used.contains(&round.category) {
            return Err("card repeats a category");
        }
        let expected = score(round.dice, round.category);
        if expected != round.points {
            return Err("card has incorrect points");
        }
        used.push(round.category);
        total += expected;
    }
    Ok(total)
}

自分のゲームをプレイして記録を再利用する

このステップでは、完成したルールを新しい出目で使い、通常のファイルパスの間違いから回復します。同じ Terminal の /home/labex/project/dice-game から続けてください。

--fixture を付けない場合、準備済みプログラムは /dev/urandom から乱数バイトを読み、各ラウンドで1から6までの出目を5個生成します。これはローカルな練習ゲームです。目標の合計や勝つための戦略は必要ありませんが、得点はルールに従う必要があります。sum、pair、straight を任意の順序で1回ずつ使ってください。拒否された単語や重複カテゴリでは現在の出目を保持します。

新しいゲームを始めます。

cargo run --quiet -- play

最初の出目を読み、未使用カテゴリを選んで Enter を押します。2ラウンド目と3ラウンド目も同様です。拒否を確認するため、意図的に1回 pairs を入力してから、使えるカテゴリへ直しても構いません。修正中に出目と rolls の数が変わらないことを見てください。3回の選択が受理されたら、3件の記録と再計算した合計を確認します。ランダムな出目は固定例と異なる場合があり、完了したゲームは match.tsv を置き換えます。

新しいカードを読み戻します。

cargo run --quiet -- replay match.tsv

正当な0点も含め、直前のゲームと記録が一致している必要があります。通常のパス間違いを練習するため、次のコマンドでは存在しないファイルを意図的に指定します。Error: missing-card.tsv: ... が表示され、ステータス1で終了するはずです。代わりのカードを生成してはいけません。

cargo run --quiet -- replay missing-card.tsv

実際のファイル名で回復します。

cargo run --quiet -- replay match.tsv

どちらの再計算でもカードは変更されず、引き続き読み取れます。src/main.rs、Cargo.toml、公開 API を維持してください。チェックは隔離したコピーで元の実装を実行し、実際の match.tsv を読みます。代わりにカードを作成することはありません。

まとめ

ゲームとその記録をモデル化し、成功と拒否を明示的に返し、検証後にだけ状態を変更しました。無効な選択では出目を保持し、正当な0点ではゲームを進め、再計算では完全なスコアカードを検証しました。新しい出目で遊び、以前の VM に依存せず、実際に保存されたファイルを再利用しました。