npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@mp-gumi/riddle-progress

v0.1.0

Published

Headless, local-first progress state for browser-based riddles

Readme

@mp-gumi/riddle-progress

ブラウザ謎解き向けの、UIを持たない進行管理ライブラリです。回答、カウンター、チェックポイント、独自ギミック、閲覧済みヒントを localStorage に保存します。

インストール

npm install @mp-gumi/riddle-progress zustand

Reactから使う場合はReact 18以上とZustand 5以上が必要です。コアAPIだけを使う場合、ReactとZustandは不要です。

基本設定

// progress.ts
import { createRiddleProgress } from '@mp-gumi/riddle-progress/zustand'

const steps = {
  q1: {
    kind: 'answer',
    correctAnswers: ['あいこ', '愛子'],
    hintIds: ['q1-hint-1', 'q1-hint-2'],
  },
  hiddenImage: {
    kind: 'counter',
    target: 5,
  },
  prologueRead: {
    kind: 'checkpoint',
  },
  collectedPieces: {
    kind: 'custom',
    initialData: { ids: [] as string[] },
    validateData: (value: unknown): value is { ids: string[] } =>
      typeof value === 'object' &&
      value !== null &&
      Array.isArray((value as { ids?: unknown }).ids),
    isComplete: (data: { ids: string[] }) => data.ids.length >= 3,
  },
} as const

export const useProgress = createRiddleProgress({
  storageKey: 'my-riddle-progress',
  version: 1,
  steps,
})

storageKey は作品ごとに固有の値にしてください。同一オリジン上の別作品との衝突を防ぎます。

Reactで使う

回答を保存・判定する

import { useProgress } from './progress'

export function AnswerForm() {
  const state = useProgress((store) => store.progress.steps.q1)
  const setAnswerDraft = useProgress((store) => store.setAnswerDraft)
  const submitAnswer = useProgress((store) => store.submitAnswer)

  return (
    <form
      onSubmit={(event) => {
        event.preventDefault()
        submitAnswer('q1', state.answer)
      }}
    >
      <input
        value={state.answer}
        disabled={state.completed}
        onChange={(event) => setAnswerDraft('q1', event.target.value)}
      />
      <button disabled={state.completed}>回答する</button>
      {state.result === 'incorrect' && <p>不正解です</p>}
    </form>
  )
}

正解済みステップは固定され、その後の回答やデータ更新では巻き戻りません。

画像を5回タップする

const tapState = useProgress((store) => store.progress.steps.hiddenImage)
const incrementCounter = useProgress((store) => store.incrementCounter)

<img
  src="/hidden.png"
  onClick={() => incrementCounter('hiddenImage')}
  aria-label={`${tapState.value}回タップ済み`}
/>

ヒントを順番に開く

ヒント本文はサイト側で管理し、ライブラリにはIDだけを設定します。

const hints = {
  'q1-hint-1': '最初の文字に注目',
  'q1-hint-2': '縦に読んでみよう',
}

const step = useProgress((store) => store.progress.steps.q1)
const revealNextHint = useProgress((store) => store.revealNextHint)

const visibleHints = step.revealedHintIds.map((id) => hints[id])
revealNextHint('q1')

revealHint(stepId, hintId) で指定したヒントだけを開くこともできます。

サイト側で進行を判定する

解放条件やクリア条件はライブラリに含まれません。保存状態からサイト側で判定します。

const progress = useProgress((store) => store.progress)

const finalUnlocked =
  progress.steps.q1.completed && progress.steps.hiddenImage.completed

独自ギミックを更新する

const updateStep = useProgress((store) => store.updateStep)

updateStep('collectedPieces', (step) => ({
  ...step,
  data: {
    ids: step.data.ids.includes('red')
      ? step.data.ids
      : [...step.data.ids, 'red'],
  },
}))

custom の validateData は、保存データの復元時と汎用更新時の実行時検証にも使われます。

全進行をリセットする

const resetAll = useProgress((store) => store.resetAll)

<button
  onClick={() => {
    if (window.confirm('進行状況をすべてリセットしますか?')) {
      resetAll()
    }
  }}
>
  リセット
</button>

確認UIやリセットを起動するクリック箇所はサイト側で自由に実装できます。

回答の正規化

標準では次の処理を行います。

  • 前後の空白を除去
  • Unicode NFKC正規化(全角英数字などを統一)
  • 英字の大小文字を無視
  • ひらがな・カタカナを同一視
  • 途中の空白は保持

したがって アイコ と あいこ は同じ回答として扱われます。入力値そのものを判定したい問題では exact: true を指定します。

const steps = {
  strictAnswer: {
    kind: 'answer',
    correctAnswers: ['ABC'],
    exact: true,
  },
} as const

正規化項目はステップごとに変更できます。

{
  kind: 'answer',
  correctAnswers: ['A B'],
  normalization: {
    trim: true,
    caseInsensitive: true,
    unicode: 'NFKC',
    kanaInsensitive: true,
    internalWhitespace: 'remove',
  },
}

独自判定も同期関数として指定できます。

{
  kind: 'answer',
  validate: (raw, { normalizedAnswer, normalize }) =>
    normalizedAnswer === normalize('特別な答え') && raw.length < 20,
}

非同期判定はサイト側で行い、結果に応じて独自ステップを更新してください。

バージョン移行

保存形式を変更したら version を上げ、移行先バージョンをキーにした関数を追加します。

createRiddleProgress({
  storageKey: 'my-riddle-progress',
  version: 2,
  steps,
  migrations: {
    2: (oldProgress) => ({
      ...oldProgress,
      steps: {
        ...oldProgress.steps,
        newId: oldProgress.steps.oldId,
      },
    }),
  },
})

追加されたステップは自動的に初期状態で補完され、削除されたステップの保存値は読み込み時に除外されます。必要な移行関数がない場合、初期状態で起動し、元データは resetAll() まで上書きしません。

保存エラー

createRiddleProgress({
  storageKey: 'my-riddle-progress',
  version: 1,
  steps,
  onStorageError: (error, { operation, storageKey }) => {
    console.error(operation, storageKey, error)
  },
})

壊れたJSON、未対応バージョン、容量超過などがあっても、サイトは初期状態で動作を続けます。破損データを自動で上書きしないため、保存は resetAll() が成功するまで停止します。

複数タブ同期

同じ作品を複数タブで開いた場合、標準では storage イベントによって状態を同期します。不要なら無効化できます。

createRiddleProgress({
  storageKey: 'my-riddle-progress',
  version: 1,
  steps,
  syncTabs: false,
})

ストアを破棄する環境では、リスナーも解除してください。

useProgress.dispose()

Zustandを使わない場合

パッケージ本体から、純粋な状態更新関数と localStorage 永続化APIを利用できます。

import {
  createInitialProgress,
  createProgressPersistence,
  incrementCounter,
} from '@mp-gumi/riddle-progress'

let progress = createInitialProgress(steps, 1)
progress = incrementCounter(progress, steps, 'hiddenImage')

const persistence = createProgressPersistence({
  storageKey: 'my-riddle-progress',
  version: 1,
  steps,
})

persistence.save(progress)

開発

npm install
npm test
npm run pack:check