import { getAuth, onAuthStateChanged } from "firebase/auth";
import { mapGetters } from "vuex";
+import {
+ saveSanpaiResult,
+ loadRestorableSanpaiResult,
+} from "~/utils/sanpaiSession";
// 認証状態の復元は非同期のため、最初に確定したユーザーを待ち受ける
function resolveCurrentUser(auth) {
@@ -213,8 +231,23 @@ export default {
},
};
},
+ mounted() {
+ // SWの自動リロード・PWA再起動・タブ退避復帰などで再マウントされた場合、
+ // クールダウン内なら儀式をやり直させず完了画面を復元する。儀式画面に
+ // 戻すと二拍手→サーバー判定でexpireの悪循環になる(#198)
+ const saved = loadRestorableSanpaiResult(Date.now());
+ if (saved) {
+ this.ritual = false;
+ this.result = "success";
+ this.status = { ...this.status, ...saved };
+ return;
+ }
+ // 儀式〜参拝完了まではSWの自動リロードを保留させる(#198)
+ this.setSwReloadBlocked(true);
+ },
beforeDestroy() {
if (this.clapTimerId) clearTimeout(this.clapTimerId);
+ this.setSwReloadBlocked(false);
},
methods: {
// 二拍手の判定。dblclick はモバイルで不安定なため click を自前でカウントする。
@@ -242,6 +275,19 @@ export default {
this.claps = 0; // 時間切れ:改めて2回必要
}, 700);
},
+ // 通信失敗後の再試行。儀式(二拍手)は済んでいるのでAPIだけやり直す
+ onRetry() {
+ this.isError = false;
+ this.isLoading = true;
+ this.setSwReloadBlocked(true);
+ this.doSanpai();
+ },
+ // sw-update.client.js が公開するガード。儀式〜参拝API実行中の
+ // SW自動リロードを保留する(#198)
+ setSwReloadBlocked(blocked) {
+ const guard = typeof window !== "undefined" && window.$swReloadGuard;
+ if (guard) guard.blocked = blocked;
+ },
// 拍手のフィードバック(バウンス再生+波紋リング追加)
pop() {
this.popKey += 1;
@@ -268,36 +314,45 @@ export default {
};
// Go版(sanpaiGo)はコールドスタートが短く参拝処理が速くなるため使用する
// (Node版のsanpaiとレスポンス形式は同一。docs/backend.md参照)
- let response = await this.$axios.post("sanpaiGo",
- payload,
- {
- headers: {
- Authorization: `Bearer ${token}`
- }
- })
- .catch(e=>{
- this.$store.dispatch('logout');
- this.isLoading = false;
- })
- if (response) {
- const d = response.data;
- this.status.level = d.level;
- this.status.get = d.add_exp;
- this.status.point = d.exp;
- this.status.msg = d.msg;
- this.status.pointsBefore = d.points_before != null ? d.points_before : 0;
- this.status.pointsAfter = d.points_after != null ? d.points_after : d.exp;
- this.status.powerBefore = d.power_before != null ? d.power_before : 0;
- this.status.powerAfter = d.power_after != null ? d.power_after : 0;
- this.status.levelBefore = d.level_before != null ? d.level_before : 0;
- this.status.levelAfter = d.level_after != null ? d.level_after : d.level;
- this.status.updatedRepoCount = d.updated_repo_count != null ? d.updated_repo_count : 0;
- this.status.actionCount = d.action_count != null ? d.action_count : 0;
- this.result = d.status;
- this.isLoading = false;
- } else {
- this.isError = true;
+ let response;
+ try {
+ response = await this.$axios.post("sanpaiGo",
+ payload,
+ {
+ headers: {
+ Authorization: `Bearer ${token}`
+ }
+ });
+ } catch (e) {
+ // ログアウトは認証エラー(401/403)のときだけ。ネットワーク瞬断や5xxで
+ // ログアウトさせると、再サインイン→再参拝→expireの悪循環になる(#198)
+ const statusCode = e.response && e.response.status;
+ if (statusCode === 401 || statusCode === 403) {
+ this.$store.dispatch("logout");
+ } else {
+ this.isError = true;
+ }
this.isLoading = false;
+ return;
+ }
+ const d = response.data;
+ this.status.level = d.level;
+ this.status.get = d.add_exp;
+ this.status.point = d.exp;
+ this.status.msg = d.msg;
+ this.status.pointsBefore = d.points_before != null ? d.points_before : 0;
+ this.status.pointsAfter = d.points_after != null ? d.points_after : d.exp;
+ this.status.powerBefore = d.power_before != null ? d.power_before : 0;
+ this.status.powerAfter = d.power_after != null ? d.power_after : 0;
+ this.status.levelBefore = d.level_before != null ? d.level_before : 0;
+ this.status.levelAfter = d.level_after != null ? d.level_after : d.level;
+ this.status.updatedRepoCount = d.updated_repo_count != null ? d.updated_repo_count : 0;
+ this.status.actionCount = d.action_count != null ? d.action_count : 0;
+ this.result = d.status;
+ this.isLoading = false;
+ if (d.status === "success") {
+ // クールダウン内の再マウントで完了画面を復元できるよう保存(#198)
+ saveSanpaiResult({ ...this.status }, d.next_time, Date.now());
}
},
},
diff --git a/web/plugins/sw-update.client.js b/web/plugins/sw-update.client.js
index 0f6c1d7..37c6b02 100644
--- a/web/plugins/sw-update.client.js
+++ b/web/plugins/sw-update.client.js
@@ -4,9 +4,17 @@
// activate して開いているタブの制御を奪う(controllerchange が発火する)。
// ここではその切替を検知して自動リロードし、さらに開いたままのタブでも
// 新デプロイを検知できるよう定期的に update() を呼ぶ。
-export default () => {
+//
+// ただし参拝の儀式〜参拝API実行中にリロードすると儀式画面からやり直しになり、
+// 再拍手→expire の原因になる(#198)。画面側が window.$swReloadGuard.blocked を
+// 立てている間はリロードを保留し、ブロック解除後の画面遷移時に実施する。
+export default ({ app }) => {
if (!("serviceWorker" in navigator)) return;
+ // 「今リロードされると困る」画面(参拝の儀式中など)が blocked を立てるガード
+ const guard = { blocked: false, pending: false };
+ window.$swReloadGuard = guard;
+
// プラグイン起動時点で既に SW に制御されているか。
// 初回インストール(制御なし→あり)ではリロード不要なため区別する。
// 初回インストール後は制御下に入るので true に更新する(固定のままだと、
@@ -14,6 +22,12 @@ export default () => {
let hasController = !!navigator.serviceWorker.controller;
let reloading = false;
+ const reload = () => {
+ if (reloading) return;
+ reloading = true;
+ window.location.reload();
+ };
+
navigator.serviceWorker.addEventListener("controllerchange", () => {
if (!hasController) {
// 初回インストールによる制御開始。リロードは不要だが、次回以降の
@@ -21,11 +35,23 @@ export default () => {
hasController = true;
return;
}
- if (reloading) return;
- reloading = true;
- window.location.reload();
+ if (guard.blocked) {
+ guard.pending = true;
+ return;
+ }
+ reload();
});
+ // 保留したリロードは画面遷移後に実施(表示中の儀式・結果を突然消さない)
+ if (app.router) {
+ app.router.afterEach(() => {
+ if (guard.pending && !guard.blocked) {
+ guard.pending = false;
+ reload();
+ }
+ });
+ }
+
navigator.serviceWorker.ready.then((registration) => {
const checkForUpdate = () => {
registration.update().catch(() => {});
diff --git a/web/scripts/simulate-omikuji.js b/web/scripts/simulate-omikuji.js
new file mode 100644
index 0000000..f308e10
--- /dev/null
+++ b/web/scripts/simulate-omikuji.js
@@ -0,0 +1,151 @@
+// おみくじからくり装置(omikujiMachine.js)のヘッドレス検証(#199)。
+//
+// 装置は「儀式の長さ(=玉1投入までの経過ステップ数)」だけで初期状態が決まる
+// 決定論設計なので、その1次元を細かく掃引すれば実機で起こり得る軌道を
+// 網羅的に再現できる。パラメータ調整(GEO)や物理修正の際は必ずここを回す。
+//
+// 使い方(web/ ディレクトリで実行。matter-js が require できること):
+// node scripts/simulate-omikuji.js --sweep 601 儀式0〜10秒を掃引
+// node scripts/simulate-omikuji.js --sweep 1801 --max-ritual 30 0〜30秒を1step刻みで掃引
+// node scripts/simulate-omikuji.js --jitter 無操作60秒の微振動(震え)を計測
+// node scripts/simulate-omikuji.js --sweep 601 --no-assist 連鎖アシスト無効で掃引
+//
+// 検証項目:
+// - ドミノ5枚が全て倒れるか(倒れ残り = 連鎖の途中停止)
+// - 玉2が狐のセンサーに到達するか、到達までの秒数
+// - (--jitter)静止しているべき絵馬・水車・鈴の緒の振動振幅
+
+/* eslint-disable no-console */
+const Matter = require("matter-js");
+const machine = require("../components/omikujiMachine.js");
+
+const STEPS_PER_SEC = 1000 / machine.GEO.FIXED_DELTA;
+
+function runOnce(opts) {
+ const { warmupSteps, maxSeconds, assist } = opts;
+ const built = machine.buildMachineWorld(Matter);
+ const engine = built.engine;
+
+ if (assist && typeof machine.installChainAssist === "function") {
+ machine.installChainAssist(Matter, engine);
+ }
+
+ let step = 0;
+ let foxHitStep = null;
+ Matter.Events.on(engine, "collisionStart", (e) => {
+ for (const p of e.pairs) {
+ const labels = [p.bodyA.label, p.bodyB.label];
+ if (labels.includes("fox-sensor") && (labels.includes("ball") || labels.includes("ema"))) {
+ if (foxHitStep === null) foxHitStep = step;
+ }
+ }
+ });
+
+ // 儀式パート(鈴の緒を振っている間)。装置には触れないので経過ステップのみ
+ for (let i = 0; i < warmupSteps; i++) {
+ Matter.Engine.update(engine, machine.GEO.FIXED_DELTA, 1);
+ step++;
+ }
+
+ // OmikujiScene.onRing と同じ: 300ms 後に玉1を vx=0.35 で投入
+ for (let i = 0; i < Math.round(0.3 * STEPS_PER_SEC); i++) {
+ Matter.Engine.update(engine, machine.GEO.FIXED_DELTA, 1);
+ step++;
+ }
+ machine.spawnBall(Matter, engine.world, 0.35);
+
+ const maxSteps = step + Math.round(maxSeconds * STEPS_PER_SEC);
+ while (step < maxSteps && foxHitStep === null) {
+ Matter.Engine.update(engine, machine.GEO.FIXED_DELTA, 1);
+ step++;
+ }
+
+ const dominoes = Matter.Composite.allBodies(engine.world).filter((b) => b.label === "domino");
+ // 事前傾斜(-0.17)から転倒方向(左)へ大きく倒れていれば「倒れた」
+ const fallen = dominoes.filter((d) => d.angle < -0.9).length;
+
+ return {
+ foxHitSec: foxHitStep === null ? null : (foxHitStep - warmupSteps) / STEPS_PER_SEC,
+ dominoesFallen: fallen,
+ dominoTotal: dominoes.length,
+ };
+}
+
+// 無操作のまま回し、静止しているべきボディの振動振幅を計測する。
+// warmupSec 経過後の windowSec 間で、各ボディの位置/角度の最大変位を取る。
+function measureJitter(warmupSec, windowSec) {
+ const built = machine.buildMachineWorld(Matter);
+ const engine = built.engine;
+ const bodies = Matter.Composite.allBodies(engine.world);
+ const watch = [
+ ...bodies.filter((b) => b.label === "ema").map((b, i) => ({ name: "ema" + i, b })),
+ ...bodies.filter((b) => b.label === "wheel").map((b) => ({ name: "wheel", b })),
+ { name: "tassel", b: built.tassel },
+ ];
+
+ for (let i = 0; i < warmupSec * STEPS_PER_SEC; i++) {
+ Matter.Engine.update(engine, machine.GEO.FIXED_DELTA, 1);
+ }
+ const base = watch.map((w) => ({ x: w.b.position.x, y: w.b.position.y, a: w.b.angle }));
+ const amp = watch.map(() => ({ pos: 0, ang: 0 }));
+ for (let i = 0; i < windowSec * STEPS_PER_SEC; i++) {
+ Matter.Engine.update(engine, machine.GEO.FIXED_DELTA, 1);
+ watch.forEach((w, k) => {
+ const dx = w.b.position.x - base[k].x;
+ const dy = w.b.position.y - base[k].y;
+ amp[k].pos = Math.max(amp[k].pos, Math.hypot(dx, dy));
+ amp[k].ang = Math.max(amp[k].ang, Math.abs(w.b.angle - base[k].a));
+ });
+ }
+ watch.forEach((w, k) => {
+ console.log(
+ `${w.name.padEnd(7)} 変位 ${amp[k].pos.toFixed(4)}px / 角度 ${amp[k].ang.toFixed(5)}rad` +
+ (w.b.isSleeping ? " (sleeping)" : "")
+ );
+ });
+}
+
+function main() {
+ const args = process.argv.slice(2);
+ const flag = (name) => args.includes(name);
+ const num = (name, def) => {
+ const i = args.indexOf(name);
+ return i >= 0 && args[i + 1] ? parseInt(args[i + 1], 10) : def;
+ };
+
+ if (flag("--jitter")) {
+ console.log("== 無操作60秒後の5秒間の微振動(震え)==");
+ measureJitter(60, 5);
+ return;
+ }
+
+ const points = num("--sweep", 121);
+ const assist = !flag("--no-assist");
+ const maxWarmup = num("--max-ritual", 10) * STEPS_PER_SEC;
+ let fail = 0;
+ const failures = [];
+ let slowest = 0;
+ for (let i = 0; i < points; i++) {
+ const warmupSteps = Math.round((i / Math.max(1, points - 1)) * maxWarmup);
+ const r = runOnce({ warmupSteps, maxSeconds: 25, assist });
+ const ok = r.foxHitSec !== null && r.dominoesFallen === r.dominoTotal;
+ if (!ok) {
+ fail++;
+ failures.push({ warmupSteps, ...r });
+ }
+ if (r.foxHitSec !== null) slowest = Math.max(slowest, r.foxHitSec);
+ if (points > 60 && i % 60 === 0) {
+ process.stdout.write(`\r${i}/${points} (fail ${fail}) `);
+ }
+ }
+ console.log(`\n掃引 ${points} 点: 失敗 ${fail} / 最遅到達 ${slowest.toFixed(1)}s (assist=${assist})`);
+ for (const f of failures.slice(0, 20)) {
+ console.log(
+ ` warmup=${f.warmupSteps}step ドミノ ${f.dominoesFallen}/${f.dominoTotal} ` +
+ `狐到達 ${f.foxHitSec === null ? "なし" : f.foxHitSec.toFixed(1) + "s"}`
+ );
+ }
+ process.exitCode = fail > 0 ? 1 : 0;
+}
+
+main();
diff --git a/web/utils/sanpaiSession.js b/web/utils/sanpaiSession.js
new file mode 100644
index 0000000..a2ab5b0
--- /dev/null
+++ b/web/utils/sanpaiSession.js
@@ -0,0 +1,97 @@
+// 参拝結果のローカル永続化(#198)。
+//
+// 儀式画面フラグ(ritual)は sanpai.vue のローカルstateのため、SWの自動リロード・
+// PWA再起動・モバイルのタブ退避復帰などで再マウントされると、参拝直後でも
+// 儀式画面に戻ってしまい、再度二拍手するとサーバー判定で expire になる。
+// これを防ぐため、参拝成功の結果をlocalStorageに保存し、クールダウン内の
+// 再マウントでは完了画面を復元する。
+//
+// 判定ロジックは storage を注入できる純関数にしてあり、Nodeで決定論的に
+// 検証できる(sanpaiGrass.js と同じ流儀)。
+
+var STORAGE_KEY = "debug-shrine:last-sanpai-result";
+
+// サーバー(SANPAI_NEXT_TIME_SECONDS)の既定値と同じ。応答に next_time が
+// 含まれない旧デプロイへのフォールバック。
+var DEFAULT_NEXT_TIME_SECONDS = 60;
+
+function defaultStorage() {
+ // プライベートモード等で localStorage が使えない環境では保存を諦める
+ try {
+ return window.localStorage;
+ } catch (e) {
+ return null;
+ }
+}
+
+// 参拝成功時に呼ぶ。status は完了画面の表示に必要な値一式(プレーンオブジェクト)。
+function saveSanpaiResult(status, nextTimeSeconds, now, storage) {
+ storage = storage || defaultStorage();
+ if (!storage) return;
+ var record = {
+ savedAt: now,
+ nextTime:
+ nextTimeSeconds != null ? nextTimeSeconds : DEFAULT_NEXT_TIME_SECONDS,
+ status: status,
+ };
+ try {
+ storage.setItem(STORAGE_KEY, JSON.stringify(record));
+ } catch (e) {
+ // 容量超過等。保存できなくても参拝自体は成立しているので黙って続行
+ }
+}
+
+// マウント時に呼ぶ。クールダウン内なら保存した status を返し、
+// 期限切れ・壊れたデータは削除して null を返す。
+function loadRestorableSanpaiResult(now, storage) {
+ storage = storage || defaultStorage();
+ if (!storage) return null;
+ var raw;
+ try {
+ raw = storage.getItem(STORAGE_KEY);
+ } catch (e) {
+ return null;
+ }
+ if (!raw) return null;
+ var record;
+ try {
+ record = JSON.parse(raw);
+ } catch (e) {
+ clearSanpaiResult(storage);
+ return null;
+ }
+ if (
+ !record ||
+ typeof record.savedAt !== "number" ||
+ typeof record.nextTime !== "number" ||
+ !record.status
+ ) {
+ clearSanpaiResult(storage);
+ return null;
+ }
+ var expiresAt = record.savedAt + record.nextTime * 1000;
+ // 端末時計が巻き戻っている場合(savedAt が未来)も復元せず捨てる
+ if (now < record.savedAt || now >= expiresAt) {
+ clearSanpaiResult(storage);
+ return null;
+ }
+ return record.status;
+}
+
+function clearSanpaiResult(storage) {
+ storage = storage || defaultStorage();
+ if (!storage) return;
+ try {
+ storage.removeItem(STORAGE_KEY);
+ } catch (e) {
+ // noop
+ }
+}
+
+module.exports = {
+ STORAGE_KEY: STORAGE_KEY,
+ DEFAULT_NEXT_TIME_SECONDS: DEFAULT_NEXT_TIME_SECONDS,
+ saveSanpaiResult: saveSanpaiResult,
+ loadRestorableSanpaiResult: loadRestorableSanpaiResult,
+ clearSanpaiResult: clearSanpaiResult,
+};