Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions docs/backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -444,6 +444,27 @@ Node版との差分(意図的な改善):
「引く」ボタン押下時に peek 無しで実際に抽選する。おみくじには参拝のようなポイント等の
ゲーム報酬は無く、フレーバー(占い)のみ。

### 引けるかどうかを開く前に伝える

引けるかどうかは**前回いつ引いたか**だけで決まり、それは前回引いた瞬間に確定して
いる。にもかかわらずどこにも覚えていなかったため、「おみくじ」を開くたびに
トークン取得 + `omikujiGo`(peek) の往復(関数がコールドなら数秒)を待たされ、
そのあとで初めて「まだ引けない」と分かっていた。

- `web/utils/omikujiCooldown.js` が**次に引ける時刻と前回の結果**を localStorage に
持つ(キーに `github_id` を含め、アカウント切替で前の人のものを見せない)。
純関数で `storage` を注入でき、`web/scripts/test-omikuji-cooldown.js` で検証する。
- **トップページ**: クールダウン中は「次のおみくじまで ◯時間◯分」を出し、
ボタンを「前回のおみくじ」に変える。引ければ「おみくじを引く」に戻る。
ここでは**サーバーに問い合わせない**(前回いつ引いたかだけで決まるため)。
- **おみくじページ**: マウント時に保存した状態を読み、**通信を待たずに**
前回の結果とカウントダウンを出す。peek の応答が来たら上書きする(正はサーバー)。
クールダウンが明けていて前回の結果だけある場合は、結果を添えてボタンを出す。
- **ヘッダ**: 同じく引けないときは「前回のおみくじ」表記。
- あくまで表示を先出しするだけで、**実際に引けるかは常にサーバーが決める**
(端末の時計は書き換えられる)。残り時間は8時間(最長のクールダウン)で頭打ち。
別端末では保存が無いので、従来どおり peek の応答から復元される。

### フロント

- `web/pages/omikuji.vue`(状態管理・API呼び出し)と `web/components/OmikujiResult.vue`
Expand Down
45 changes: 41 additions & 4 deletions web/components/CommonHeader.vue
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,9 @@
</ul>
<ul class="navbar-nav mb-2 mb-lg-0" v-if="isLogin">
<li class="nav-item">
<nuxt-link class="nav-link active" to="/omikuji">おみくじ</nuxt-link>
<nuxt-link class="nav-link active" to="/omikuji">{{
omikujiLabel
}}</nuxt-link>
</li>
<li class="nav-item">
<nuxt-link
Expand All @@ -61,19 +63,54 @@

<script>
import { mapGetters } from "vuex";
import { loadOmikujiRemaining } from "@/utils/omikujiCooldown";

export default {
data() {
return {};
return {
// おみくじの残り秒(0なら引ける)。開く前に分かるようにリンクの文言へ出す。
omikujiRemaining: 0,
omikujiTimerId: null,
};
},
mounted() {
this.refreshOmikuji();
// 残り時間はページを開かなくても減っていく。30秒ごとに見直して、
// 引けるようになったらリンクの文言を戻す。
this.omikujiTimerId = setInterval(this.refreshOmikuji, 30 * 1000);
},
beforeDestroy() {
if (this.omikujiTimerId) clearInterval(this.omikujiTimerId);
},
mounted() {},
methods: {
logout() {
this.$store.dispatch("logout");
},
refreshOmikuji() {
this.omikujiRemaining = loadOmikujiRemaining(
this.user && this.user.github_id,
Date.now()
);
},
},
computed: {
...mapGetters(["isLogin"]),
...mapGetters(["isLogin", "user"]),
// 引けないと分かっているときは「前回のおみくじ」。押す前に、引けるのか
// 前回の結果を見に行くだけなのかが分かる。
omikujiLabel() {
return this.omikujiRemaining > 0 ? "前回のおみくじ" : "おみくじ";
},
},
watch: {
// ログイン直後やアカウント切替でユーザーが変わったら取り直す
// (キーに github_id が入っているため)。
user() {
this.refreshOmikuji();
},
// ページ遷移のたびに見直す(おみくじを引いた直後に文言が変わる)。
$route() {
this.refreshOmikuji();
},
},
};
</script>
38 changes: 37 additions & 1 deletion web/pages/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,16 @@
>
参拝する
</button>
<!-- 引けないと分かっているときは「前回のおみくじ」。押せば
前回の結果がその場で出る(通信を待たない)。 -->
<nuxt-link to="/omikuji" class="btn btn-lg btn-outline-light">
おみくじを引く
{{ omikujiRemaining > 0 ? "前回のおみくじ" : "おみくじを引く" }}
</nuxt-link>
</div>
<div v-if="omikujiRemaining > 0" class="mt-2 small text-muted">
次のおみくじまで
<span class="fw-bold">{{ omikujiRemainingText }}</span>
</div>
<div class="mt-4 p-2 d-inline-block">
<div class="rounded border p-2 w-100 mb-2" v-if="isLogin">
<img
Expand Down Expand Up @@ -148,6 +154,10 @@ import {
} from "firebase/auth";
import { mapGetters } from "vuex";
import Ranking from "@/components/Ranking";
import {
loadOmikujiRemaining,
formatOmikujiRemaining,
} from "@/utils/omikujiCooldown";

export default {
layout: "single",
Expand All @@ -163,16 +173,33 @@ export default {
registerError: false,
// らぼみの吹き出し。SSR/CSRの差異を避けるため mounted で選ぶ(初期は空)。
labomiLine: "",
// 次のおみくじまでの残り秒(0なら引ける)。localStorage から読むだけで、
// サーバーには問い合わせない(引けるかは前回いつ引いたかだけで決まる)。
omikujiRemaining: 0,
omikujiTimerId: null,
};
},
mounted() {
this.pickLabomiLine();
this.refreshOmikuji();
// 1秒ごとに保存済みの「次に引ける時刻」から計算し直す。残り秒を引き算で
// 減らすと、タブが背面のとき setInterval が間引かれて実際より長い残り時間を
// 表示してしまい、引けるのに「前回のおみくじ」のままになる。
this.omikujiTimerId = setInterval(this.refreshOmikuji, 1000);
},
beforeDestroy() {
if (this.omikujiTimerId) clearInterval(this.omikujiTimerId);
},
watch: {
// ログイン状態が変わったら(認証復元含む)セリフプールを切り替える
isLogin() {
this.pickLabomiLine();
},
// 認証復元でユーザーが入ったら残り時間を読み直す
// (保存キーに github_id が入っているため)。
user() {
this.refreshOmikuji();
},
},
async beforeMount() {
const auth = getAuth();
Expand All @@ -186,6 +213,12 @@ export default {
});
},
methods: {
refreshOmikuji() {
this.omikujiRemaining = loadOmikujiRemaining(
this.user && this.user.github_id,
Date.now()
);
},
// らぼみのセリフ(オタクに優しいギャル。口調は docs/character.md 準拠)。
// 表示のたびにランダムで1つ選び、再訪の楽しみにする。
pickLabomiLine() {
Expand Down Expand Up @@ -274,6 +307,9 @@ export default {
},
computed: {
...mapGetters(["isLogin", "user"]),
omikujiRemainingText() {
return formatOmikujiRemaining(this.omikujiRemaining);
},
},
};
</script>
Expand Down
73 changes: 64 additions & 9 deletions web/pages/omikuji.vue
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,11 @@ import { getAuth, onAuthStateChanged } from "firebase/auth";
import { mapGetters } from "vuex";
import ResultCard from "@/components/OmikujiResult";
import OmikujiScene from "@/components/OmikujiScene";
import {
saveOmikujiState,
loadOmikujiState,
formatOmikujiRemaining,
} from "@/utils/omikujiCooldown";

function resolveCurrentUser(auth) {
return new Promise((resolve) => {
Expand All @@ -110,6 +115,24 @@ export default {
};
},
async mounted() {
// 覚えているものがあれば、通信を待たずにその場で出す。ここで待たせると
// 「押してから待たされた末に引けないと分かる」になる。
// サーバーの応答が来たら上書きされる(正はあくまでサーバー)。
const saved = loadOmikujiState(
this.user && this.user.github_id,
Date.now()
);
if (saved) {
this.result = saved.result;
if (saved.remainingSeconds > 0) {
this.remaining = saved.remainingSeconds;
this.state = "cooldown";
this.startTimer();
} else if (saved.result) {
// 引ける。前回の結果を添えてボタンを出す。
this.state = "available";
}
}
await this.fetchStatus();
},
beforeDestroy() {
Expand All @@ -129,7 +152,11 @@ export default {
},
// 引かずに現在の状態(引ける/クールダウン+前回結果)を取得する。
async fetchStatus() {
this.state = "loading";
// 既に出せるものがあるなら、確認中もそれを出したままにする。
// ローディングに戻すと、せっかく即表示した意味が無くなる。
if (this.state !== "cooldown" && this.state !== "available") {
this.state = "loading";
}
const token = await this.getToken();
if (!token) return;
try {
Expand Down Expand Up @@ -182,13 +209,32 @@ export default {
this.remaining = this._pendingRemaining || 0;
this.pendingResult = null;
this.state = "cooldown";
saveOmikujiState(
this.user && this.user.github_id,
this.remaining,
this.result,
Date.now()
);
this.startTimer();
} else {
// 演出が結果より先に終わった(スキップ等)→ サーバー状態を取り直す
this.fetchStatus();
}
},
applyResponse(d) {
// 次に引ける時刻と前回の結果を覚えておく(次に開いたとき待たせないため)。
if (
d.status === "success" ||
d.status === "cooldown" ||
d.status === "available"
) {
saveOmikujiState(
this.user && this.user.github_id,
d.remaining_seconds || 0,
d.result || this.result,
Date.now()
);
}
if (d.status === "success") {
this.result = d.result;
this.remaining = d.remaining_seconds || 0;
Expand All @@ -212,28 +258,37 @@ export default {
},
startTimer() {
if (this.timerId) clearInterval(this.timerId);
// 残り秒を1秒ずつ引くのではなく、明ける時刻を決めて毎回そこから計算する。
// タブが背面にあると setInterval は間引かれる(止まることもある)ため、
// 引き算だと戻ってきたときに実際より長い残り時間を表示してしまい、
// 「引けるのに引けないと見える」という直したかった症状が再発する。
const deadline = Date.now() + this.remaining * 1000;
this.timerId = setInterval(() => {
this.remaining -= 1;
this.remaining = Math.max(
0,
Math.ceil((deadline - Date.now()) / 1000)
);
if (this.remaining <= 0) {
clearInterval(this.timerId);
this.timerId = null;
this.remaining = 0;
// 引ける状態へ。前回結果は残しつつボタンを出す。
this.state = "available";
saveOmikujiState(
this.user && this.user.github_id,
0,
this.result,
Date.now()
);
}
}, 1000);
},
},
computed: {
...mapGetters(["user"]),
remainingText() {
const s = Math.max(0, this.remaining);
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
if (h > 0) return `${h}時間${m}分`;
if (m > 0) return `${m}分${sec}秒`;
return `${sec}秒`;
// トップページと同じ表記にする(整形は omikujiCooldown.js に一本化)。
return formatOmikujiRemaining(this.remaining);
},
shareUrl() {
return this.$config.baseUrl;
Expand Down
82 changes: 82 additions & 0 deletions web/scripts/test-omikuji-cooldown.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
// utils/omikujiCooldown.js の純関数テスト。
// 実行: node web/scripts/test-omikuji-cooldown.js
const assert = require("assert");
const m = require("../utils/omikujiCooldown");

function fakeStorage() {
const map = {};
return {
getItem: (k) => (k in map ? map[k] : null),
setItem: (k, v) => {
map[k] = v;
},
removeItem: (k) => {
delete map[k];
},
};
}

const now = 1700000000000;
const result = { tier: "大吉", fortune: "ビルドが通る" };

// 保存した残り時間と結果が読める
let s = fakeStorage();
m.saveOmikujiState("428875", 3600, result, now, s);
let got = m.loadOmikujiState("428875", now, s);
assert.strictEqual(got.remainingSeconds, 3600);
assert.deepStrictEqual(got.result, result);

// 時間が進めば減る
assert.strictEqual(
m.loadOmikujiState("428875", now + 600000, s).remainingSeconds,
3000
);

// 過ぎたら0。ただし前回の結果は残す(「前回のおみくじ」を出せるように)
got = m.loadOmikujiState("428875", now + 3600001, s);
assert.strictEqual(got.remainingSeconds, 0);
assert.deepStrictEqual(got.result, result);

// 別ユーザーの記録は見えない
s = fakeStorage();
m.saveOmikujiState("428875", 3600, result, now, s);
assert.strictEqual(m.loadOmikujiState("99999", now, s), null);

// 引ける(0)として保存しても結果は残る
s = fakeStorage();
m.saveOmikujiState("428875", 0, result, now, s);
got = m.loadOmikujiState("428875", now, s);
assert.strictEqual(got.remainingSeconds, 0);
assert.deepStrictEqual(got.result, result);

// 何も無ければ null
assert.strictEqual(m.loadOmikujiState("428875", now, fakeStorage()), null);

// 壊れた値は捨てて null
s = fakeStorage();
s.setItem(m.storageKey("428875"), "ぐちゃぐちゃ");
assert.strictEqual(m.loadOmikujiState("428875", now, s), null);
assert.strictEqual(s.getItem(m.storageKey("428875")), null);

// 端末の時計が大きくずれていても8時間で頭打ち
s = fakeStorage();
s.setItem(
m.storageKey("428875"),
JSON.stringify({ nextAt: now + 999 * 3600 * 1000, result: null })
);
assert.strictEqual(
m.loadOmikujiState("428875", now, s).remainingSeconds,
m.MAX_COOLDOWN_SECONDS
);

// localStorage が使えなくても落ちない
assert.strictEqual(m.loadOmikujiState("428875", now, null), null);
assert.strictEqual(m.loadOmikujiRemaining("428875", now, null), 0);

// 表示用の整形
assert.strictEqual(m.formatOmikujiRemaining(2 * 3600 + 30 * 60), "2時間30分");
assert.strictEqual(m.formatOmikujiRemaining(45 * 60 + 5), "45分5秒");
assert.strictEqual(m.formatOmikujiRemaining(30), "30秒");
assert.strictEqual(m.formatOmikujiRemaining(-1), "0秒");

console.log("omikujiCooldown: 全て通過");
Loading
Loading