🕖

サーバーなしゲームで端末時間に依存しない時間管理機能を実装した話

に公開

はじめに

Unityでゲームを作っている際に端末時間を変更されると不正ができる問題に直面しました。(スタミナ関係など)ここでは、その際に日本標準時(JST)グループ様(以降NICT様)が提供しているAPIを使用して不正防止の機能を実装した内容についてご紹介します。

なおこの手の内容はいたるところにありますが、自分の備忘録として、かつ他の記事にはない+αを書いていこうと思います。

なお、実装に関してnico様のサイトをとても参考にさせていただきました。

対象者

自前でサーバーは用意できないが端末時間に依存しない時間管理システムに興味のある方

環境

Unity 2022.3.27f1

注意事項

NICT様のAPIを使用して日時を取得する回数には1日当たりの上限が決められています。
1日最大480回となっており、これを超える場合はブロックされる可能性があるとのことです。
そのため実装する際はアクセス回数が増えすぎないように気を付けてください。

NICT様から日本標準時を取得する方法

private static readonly string NTP_URL = "https://3fe5a5f690efc790d4764f1c528a4ebb89fa4168.nict.go.jp/cgi-bin/json";

public static async Task<DateTime> RequestAsync()
    {
        using (var request = UnityWebRequest.Get(NTP_URL))
        {
            var operation = request.SendWebRequest();
            while (!operation.isDone)
                await Task.Yield();

            if (request.result != UnityWebRequest.Result.Success)
            {
                Debug.LogError($"Error fetching time: {request.error}");
                return DateTime.MinValue;
            }

            var json = JsonUtility.FromJson<NTPDate>(request.downloadHandler.text);
            var startDate = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc);
            var unixDate = startDate.AddSeconds(json.st);

            AddDateAccessTime(1);
            Save();

            Debug.Log(unixDate.ToLocalTime());
            return unixDate.ToLocalTime();
        }
    }

UnityWebRequestはUnityが標準で提供しているHTTP通信専用のクラスです。
このクラスを用いてNICT様のサーバーに日本標準時を取得するAPIを投げます。

using (var request = UnityWebRequest.Get(NTP_URL))
{
    var operation = request.SendWebRequest();
    while (!operation.isDone) // 返ってくるまで待つ
        await Task.Yield();

    if (request.result != UnityWebRequest.Result.Success) // 失敗したときの処理
    {
        Debug.LogError($"Error fetching time: {request.error}");
        return DateTime.MinValue;
    }

成功した場合、レスポンスから日時を取得します。私の場合は1日の取得上限を決めているので、AddDateAccessTime() という関数を作って、取得に成功するたびに1足すようにしてます。
また、Save() 関数は取得回数や、端末時間とのずれ、APIを投げた日時等を端末に保存する関数です。これによって、アプリを落としてもアクセス回数が保持され、日跨ぎも検知できるようになるため、アクセス数のリセットもできるようになります。

    var json = JsonUtility.FromJson<NTPDate>(request.downloadHandler.text);
    var startDate = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc);
    var unixDate = startDate.AddSeconds(json.st);
    
    AddDateAccessTime(1);
    Save();
    
    Debug.Log(unixDate.ToLocalTime());
    return unixDate.ToLocalTime();

時間の取得はnico様のものをほぼ流用させていただいてます。

ゲームシステムとしての時間確認の処理

先ほどのNICT様からの時間取得関数を呼び出す側の関数です。ここではアクセス上限を超えてなかったらリクエストを飛ばして正確な日時を取得し差分を算出します。上限を超えていたら端末に保存していた端末時間と標準時との差を取得します。

public static async Task CheckPCTimeAsync()
{
    if (GetDateAccessTime() < MAX_ACCESS_COUNT)
    {
        var ntp = await RequestAsync();

        if (ntp == DateTime.MinValue)
        {
            Debug.LogError("Failed to fetch NTP time.");
            return;
        }

        var pc = DateTime.Now.ToLocalTime();
        timeOffset = (ntp - pc).TotalSeconds;

        if (Math.Abs(timeOffset) < MAX_ALLOWED_OFFSET)
        {
            timeOffset = 0;
        }

        Debug.Log($"PC Time: {pc}, NTP Time: {ntp}, Offset: {timeOffset} seconds");
    }
    else
    {
        timeOffset = GetLastTimeOffset();
    }
}

GetDateAccessTime() 関数はリクエストを飛ばした回数を取得します。上限(MAX_ACCESS_COUNT)を超えてなかったらRequestAsync() 関数を実行し、端末時間との差分を計算します。ここでもnico様の案を採用し、一定秒数以内の誤差なら0秒とするようにしてます。GetLastTimeOffset() 関数は前回の算出した差分の値を持ってくる関数です。アクセス上限に達していた場合はやむなくこっちを使用します。(不正される可能性は残りますが、NICT様に迷惑をかけないのが最優先です。完璧に不正を防ぎたい時は自前サーバーを用意しましょう。。。)

アプリ起動時の処理

これまで標準時と端末時間のズレを確認する処理を書いてきました。このCheckPCTimeAsync() 関数を呼び出すのはアプリ起動時の1回のみです。そのため、StartAsync() 関数で呼び出すようにします。

public static async void StartAsync()
{
    await CheckPCTimeAsync();
    var dateTime = GetDateTime();
    SetLastAccessDate(dateTime);
    Save();
    CheckIsOverDate();
    Debug.Log($"Final Time: {dateTime}");
}

public static DateTime GetDateTime()
{
    var value = DateTime.Now.ToLocalTime();
    value = value.AddSeconds(timeOffset);
    return value;
}

CheckPCTimeAsync() 関数で時間のズレを更新した後、GetDateTime() 関数でズレを考慮した時間を取得します。SetLastAccessDate() 関数でリクエストした時間を更新したのち、Save() 関数で端末に保存します。CheckIsOverDate() 関数では日跨ぎしたかどうかを確認してます。日跨ぎした場合関数内でアクセス回数をリセットするようにしてます。

最後に

今回はサーバーがなくても端末時間に依存しない時間管理機能の実装方法についてご紹介しました。

参考サイト

https://robamemo.hatenablog.com/entry/2019/05/28/095146

Discussion