mizdra's blog

ぽよぐらみんぐ

Scalaで乱数ツールを書く話

はじめに

この記事はPokémon RNG Advent Calendar 2017 10日目の記事です.

adventar.org

乱数調整で楽しむ方々の間では乱数調整を支援するツールのことを乱数ツールと呼んでいます. 僕も乱数ツールを作成する内の1人であり, 時々ツールを作成しますがツールのソースコードはどうしても複雑になりがちです. たかが計算ツールといえども綺麗に書きたいですよね.

この記事ではScalaを使い, 乱数ツールを綺麗に書いてみる話をします.

…あれ?🤔

Adventarのコメント欄にはデザインパターンの話をするって書いてあったはずなのにScalaの話?🤔

デザインパターンは???🤔🤔🤔

f:id:mizdra:20171210234047p:plain
証拠です

…申し訳ありませんが今回はテーマを変えて記事を書いています🙇 本当はデザインパターンの話を書こうと思っていたのですが記事に出来るほど知見が溜まっていなかったのでボツ 🚮 となりました. デザインパターンについてはまたいつか別の機会に話そうと思います 🙏

…さて話を戻します. なぜScalaを使って乱数ツールを書くかというと, Scalaには非常に充実したコレクションライブラリが備わっており, これを用いることで乱数列に対する複雑な操作を簡単に記述できるからです*1. 乱数ツールを記述していく中で, このコレクションライブラリがいかに力を発揮していくかを感じでもらえればと思います.

前提

Iteratorを継承したLCGを作成する

まずは乱数生成器(LCG)を作成しましょう. LCGクラスにIteratorトレイトを継承させることで, 乱数列をコレクションとして扱うことができます. そうすることで, コレクションライブラリで提供される非常に多くの便利なメソッドがLCGクラスで利用できるようになります.

class LCG(seed: Int, a: Int, b: Int) extends Iterator[Int] {
  var state: Int = seed

  override def hasNext: Boolean = true
  override def next(): Int = {
    state = state * a + b
    state
  }
  def next(n: Int): Int = java.lang.Integer.remainderUnsigned(this.next(), n)
}

object Wandbox {
  def main(args: Array[String]): Unit = {
    val lcg = new LCG(0x00000000, 0x41c64e6d, 0x6073)
    println(lcg.take(5).map(_.toHexString).toList)
    // stdout: List(6073, e97e7b6a, 52713895, 31b0dde4, 8e425287)
  }
}

サンプルコードの例ではコレクションのメソッド take(), map(), toList() を使用して先頭5つの乱数を16進数表記で出力しています.

調律された乱数列を取得する

通常ではLCGで得られる生の乱数列は乱数性に問題があるため, 下半分のbitを切り落として利用されます. これを先程作成したLCGクラスを用いて書くと以下のようになります.

object Wandbox {
  def temper(state: Int): Int = state >>> 16
  
  def main(args: Array[String]): Unit = {
    val lcg = new LCG(0x00000000, 0x41c64e6d, 0x6073)
    val temperedLcg = (new LCG(0x00000000, 0x41c64e6d, 0x6073)).map(temper(_))
    
    println(lcg.take(5).map(_.toHexString).toList)
    // stdout: List(6073, e97e7b6a, 52713895, 31b0dde4, 8e425287)
    println(temperedLcg.take(5).map(_.toHexString).toList)
    // stdout: List(0, e97e, 5271, 31b0, 8e42)
  }
}

写像を表わす map() メソッドを用いて生の乱数を調律後の値へと変換しています. たったこれだけで, 生の乱数列を調律された乱数列に変換できます.

ただし, これでは map() によって返ってくる型が Iterator[Int] となってしまい, LCGクラスで実装したメソッド (def next(n: Int): Int など) を呼び出せなくなってしまいます. これは後々困ったことになるのでここではLCGクラスを継承して調律された乱数列を生成するTemperedLCGクラスを作成することにしましょう.

class TemperedLCG(seed: Int, a: Int, b: Int) extends LCG(seed, a, b) {
  override def next(): Int = super.next() >>> 16
}

object Wandbox {
  def main(args: Array[String]): Unit = {
    val lcg = new LCG(0x00000000, 0x41c64e6d, 0x6073)
    val temperedLcg = new TemperedLCG(0x00000000, 0x41c64e6d, 0x6073)
    
    println(lcg.take(5).map(_.toHexString).toList)
    // stdout: List(6073, e97e7b6a, 52713895, 31b0dde4, 8e425287)
    println(temperedLcg.take(5).map(_.toHexString).toList)
    // stdout: List(0, e97e, 5271, 31b0, 8e42)
  }
}

fork() メソッドを実装する

LCGインスタンスはmutableな操作をするので, そのまま複数のスレッドに渡すと破滅します. 次は乱数列上の5つの乱数を出力する処理を, 1消費ずつずらしながら各々のスレッドで実行する例です*2.

// 破滅する例
object Wandbox {
  def printRands(lcg: LCG, n: Int) = println(for (i <- 1 to n) yield lcg.next())
  
  def main(args: Array[String]): Unit = {
    val lcg = new LCG(0x00000000, 1, 1)
    
    (1 to 3).map(_ => {
      val f = Future {
        printRands(lcg, 5)
      }
      lcg.drop(1) // 1つずらす
      f
    }).foreach(Await.ready(_, Duration.Inf))
    // stdout:
    // Vector(4, 5, 6, 7, 8)
    // Vector(9, 10, 11, 12, 13)
    // Vector(14, 15, 16, 17, 18)
    
    // 本当は次のようになって欲しい(行については順不同):
    // Vector(1, 2, 3, 4, 5)
    // Vector(2, 3, 4, 5, 6)
    // Vector(3, 4, 5, 6, 7)
  }
}

そこで自身のクローンを作成する fork() メソッドを実装してクローンをスレッドに渡すようにしてみましょう.

import scala.concurrent._
import ExecutionContext.Implicits.global
import scala.concurrent.duration.Duration

class LCG(seed: Int, a: Int, b: Int) extends Iterator[Int] {
  // ...
  def fork(): LCG = new LCG(state, a, b)
}

class TemperedLCG(seed: Int, a: Int, b: Int) extends LCG(seed, a, b) {
  // ...
  override def fork(): TemperedLCG = new TemperedLCG(state, a, b)
}

// 破滅しない例
object Wandbox {
  def printRands(lcg: LCG, n: Int) = println(for (i <- 1 to n) yield lcg.next())
  
  def main(args: Array[String]): Unit = {
    val lcg = new LCG(0x00000000, 1, 1)
    
    (1 to 3).map(_ => {
      val forkedLcg = lcg.fork()
      val f = Future {
        printRands(forkedLcg, 5)
      }
      lcg.drop(1) // 1つずらす
      f
    }).foreach(Await.ready(_, Duration.Inf))
    // stdout:
    // Vector(1, 2, 3, 4, 5)
    // Vector(3, 4, 5, 6, 7)
    // Vector(2, 3, 4, 5, 6)
  }
}

これで並列処理でも安全にLCGインスタンスを扱うことができます.

作成したLCGをエンカウント処理で使う

ここまで作成したLCGクラスを使って3世代の野生乱数の処理を書いてみましょう. 問題を簡単にするために次のような仕様で処理を書くことにします.

  • seedは 0x00000000
  • 検索する消費数の範囲は 1 〜 100000
  • CDS個体値がVのものを検索
  • 並列処理する

…と簡単にしたと言ってもなかなか複雑そうなプログラムになりそうですが, ここでもScalaのコレクションライブラリの力が発揮されます. コードを見てみましょう*3.

class LCG(seed: Int, a: Int, b: Int) extends Iterator[Int] { ... }

class TemperedLCG(seed: Int, a: Int, b: Int) extends LCG(seed, a, b) { ... }

// エンカウントデータ
case class Encounter(frame: Int, slot: Int, level: Int, nature: Int, pid: Int, ivs: Seq[Int], item: Int) {
  override def toString(): String = {
    s"frame: $frame, slot: $slot, level: $level, nature: $nature, pid: ${pid.toHexString}, ivs: ${ivs.mkString("(", ", ", ")")}, item: $item"
  }
}

// エンカウントデータを生成するBuilder
class EncounterBuilder(wantedIvs: Seq[Range]) {
  var frame: Int = 0
  var slot: Int = 0
  var level: Int = 0
  var nature: Int = 0
  var pid: Int = 0
  var ivs: Seq[Int] = Nil
  var item: Int = 0
  def frame(frame: Int): Unit = this.frame = frame
  def slot(slot: Int): Unit = this.slot = slot
  def level(level: Int): Unit = this.level = level
  def nature(nature: Int): Unit = this.nature = nature
  def pid(pid: Int): Unit = this.pid = pid
  def ivs(ivs: Seq[Int]): Unit = this.ivs = ivs
  def item(item: Int): Unit = this.item = item

  def result(): Option[Encounter] = {
    // 個体値が条件を満たしていなければ None を返す
    val isValidIvs = (wantedIvs zip ivs).forall({ case (range, iv) => range.contains(iv) })
    if (isValidIvs) Some(Encounter(frame, slot, level, nature, pid, ivs, item))
    else None
  }
}

object Wandbox {
  def printRands(lcg: LCG, n: Int) = println(for (i <- 1 to n) yield lcg.next())
  def getPID(lid: Int, hid: Int): Int = lid.toInt | (hid.toInt << 16)
  def isValidPID(pid: Int, nature: Int): Boolean = java.lang.Integer.remainderUnsigned(pid, 25) == nature
  def getIVs(rand: Int): (Int, Int, Int) = ((rand >> 10) & 0x1F, (rand >> 5) & 0x1F, rand & 0x1F)
  def searchEncounter(lcg: LCG, builder: EncounterBuilder, frame: Int) = {
    builder.frame(frame)
    
    // 出現スロット, レベル, 性格の決定
    builder.slot(lcg.next(100))
    builder.level(lcg.next())
    val nature = lcg.next(25)
    builder.nature(nature)
    
    // (pid % 25) == nature となるような PID を探す
    val pid: Int = lcg
      .grouped(2) // List(LID, HID) の組にする
      .map(iter => getPID(iter.head, iter.tail.head))
      .find(pid => isValidPID(pid, nature))
      .get // 必ずPIDが見つかることが保証されているので getOrElse の代わりに get を使っている
    builder.pid(pid)
    
    // 個体値決定
    val (b, a, h) = getIVs(lcg.next())
    val (d, c, s) = getIVs(lcg.next())
    builder.ivs(Vector(h, a, b, c, d, s))
    
    lcg.drop(5) // 5つの乱数をスキップ
    
    // 持ち物の決定
    builder.item(lcg.next(100))
    
    // 個体が条件を満たしていれば Some(Encounter), そうでなければ None が返る
    builder.result()
  }
  
  def main(args: Array[String]): Unit = {
    val lcg = new TemperedLCG(0x00000000, 0x41c64e6d, 0x6073)
    val wantedIvs = Vector( // 検索する個体値の範囲
      0 to 31,  // h
      0 to 31,  // a
      0 to 31, // b
      31 to 31, // c
      31 to 31, // d
      31 to 31  // s
    )
    val maxFrame = 100000
    
    val results: Seq[Encounter] = (1 to maxFrame).map(frame => {
      val forkedLcg = lcg.fork()
      val builder = new EncounterBuilder(wantedIvs)
      val f: Future[Option[Encounter]] = Future {
        searchEncounter(forkedLcg, builder, frame)
      }
      lcg.drop(1) // 1つずらす
      f
    }).map(Await.result(_, Duration.Inf)).flatten
    
    results.foreach(println _)
  }
}

出力

frame: 15506, slot: 40, level: 7292, nature: 0, pid: d000755f, ivs: (6, 27, 31, 31, 31, 31), item: 38
frame: 15530, slot: 86, level: 27437, nature: 0, pid: d000755f, ivs: (6, 27, 31, 31, 31, 31), item: 38
frame: 15540, slot: 72, level: 47192, nature: 0, pid: d000755f, ivs: (6, 27, 31, 31, 31, 31), item: 38
frame: 31693, slot: 21, level: 43592, nature: 1, pid: 44b35699, ivs: (2, 19, 18, 31, 31, 31), item: 64
frame: 36963, slot: 73, level: 6376, nature: 9, pid: 8e20683d, ivs: (13, 7, 24, 31, 31, 31), item: 43
frame: 36983, slot: 85, level: 41274, nature: 9, pid: 8e20683d, ivs: (13, 7, 24, 31, 31, 31), item: 43
frame: 36993, slot: 90, level: 26931, nature: 9, pid: 8e20683d, ivs: (13, 7, 24, 31, 31, 31), item: 43

乱数列からエンカウントデータを生成する searchEncounter() では, Builderパターンを用いています*4. PIDの決定では grouped() メソッドを使い, コレクションを (LID, HID) の組にすることで2つずつ乱数を消費しながら条件を満たすPIDの検索をしています.

Builderに注目すると, Builder自身に検索する個体の条件を持たせていることがわかります. これは result() で使用していて, 条件を満たさなければエンカウントデータの代わりに条件を満たす個体が見つからなかったことを表わす None を返しています. また条件が満たされているかの判定には zip, forall を使っています. Builderパターンを採用することで個体の生成, および個体のフィルタリング処理が searchEncounter() からBuilderに切り離されることになります.

このように, Scalaのコレクションライブラリの力を借りることで乱数ツールのコードを綺麗に記述することができます 👍

おわりに

以上が Pokémon RNG Advent Calendar 2017 10日目「Scalaで乱数ツールを書く話」となります. いかがでしたでしょうか. この記事を読んでちょっとしたツールの作成でも綺麗なコードを意識して取り組むきっかけになれればと思います 😃

adventar.org

11日目は oupo さんの担当です!

参考

*1:他にも「型システムが強力だから」, 「関数型言語であるから」などの理由があります.

*2:この後すぐに出てきますが, このようなテクニックは乱数ツールの中でよく使われています.

*3:何の関係もない話ですが最初にこのコードをwandboxで書き上げた時にタブがフリーズし, 1時間の成果が水の泡になる出来事が発生しました

*4:申し訳程度のデザインパターン要素です

Emtimerの紹介

はじめに

この記事はPokémon RNG Advent Calendar 2017 一日目の記事です 🎉🎉🎉

adventar.org

今年もPokémon RNG Advent Calendarの季節がやって来ました! ポケモン最新作も発売したことですし, 皆さんの乱数調整への意気込みもいっそう高まっていると思います. 今回は皆さんの乱数調整を支援するためのツールを開発したのでその紹介をします.

Emtimerの紹介

乱数調整では, 事象を発生させるタイミングを合わせるためにカウントダウンタイマーが頻繁に使われます. 既に乱数調整のためのタイマーはいくつか公開されていますが, 多くは次のような問題点を含んでいます.

  • Adobe Flashへの依存
    • 今後数年を掛けてAdobe Flashはサポート終了される予定*1であり, Adobe Flashに依存するツールを利用するのは不適切です
    • メジャーなモバイル向けブラウザではAdobe Flashに対応していないものが殆どです
  • モバイルフレンドリーでない
    • スマートフォンタブレットといった, 画面の小さな端末のことを考慮して設計されておらず, 画面の乱れや操作への支障が生じます
    • モバイル端末を利用するユーザが急増する今, モバイル対応はとても重要です
  • マルチプラットフォーム非対応
    • Adobe Flash や特定のOSに依存するツールは様々な環境で動作させることを考慮していません
    • 例えば, Adobe Flashに依存しているツールはAdobe Flashをサポートしていない環境では動作せず, Windows向けに開発されたツールは MacOS 上では動作しません
  • 新機能の導入が困難
    • 多くのツールのソースコードは公開されておらず, ユーザが新機能を開発して追加することは困難です

これらの問題を解決するために, 新たにEmtimerというツールを開発しました.

emtimer.mizdra.net

Emtimerは乱数調整のために開発されたシンプルで扱いやすい高機能なカウントダウンタイマーです. Emtimerには次のような特徴があります.

機能の紹介

Emtimerは乱数調整で最も使われているポケモンの館のエメループ *2を参考に作られました. Emtimerにはエメループから引き継いだ機能と, より乱数調整を快適にするために追加された新機能があります.

  • エメループから引き継いだ機能
    • キーバインド
    • サウンド
    • 開始までの猶予
    • 切り上げ
    • 残り時間特大表示
  • 新機能
    • ループ
    • モバイルに最適化されたコントローラ
    • カウントダウン時間の単位切り替え
    • ハイライト
    • Progressive Web Apps

エメループから引き継いだ機能

キーバインド

カウントダウンの開始/停止をスペースキーで操作することができます. スペースキー押下で停止, 押上で開始です. この機能により簡単にタイマーを再起動できます.

サウンド

秒の桁が更新される時に「ピッ」という音を鳴らします. サウンドを有効化するか無効化するか, カウントダウン終了の何秒前から音を鳴らすかを設定できます. タイミングを合わせたいときに便利です.

開始までの猶予

「待機時間」のカウントダウンを開始する前に別途カウントダウンを設けます. 両手が塞がっていてカウントダウン開始のボタンが押せない時に便利です.

切り上げ

値を設定すると, 「待機時間」のカウントダウンを指定秒数だけ早く切り上げることができます. 甘い香りを使って乱数調整する際は甘い香りを使ったときに発生する鳴き声の長さがポケモンによって異なるので, その調整に利用すると便利でしょう.

残り時間特大表示

エメタイマーBIGから引き継いだ機能です. 残り時間の秒未満の値を赤丸の位置で表現します. 赤丸が中央に来たときが秒の桁が更新される瞬間です.

f:id:mizdra:20171130221504p:plain:w600
残り時間表示. カウントダウン中は1秒ごとに赤丸が左から右へと流れていく.

新機能

ループ

タイマーの1サイクル (開始までの猶予のカウントダウン+待機時間のカウントダウン) をループします. ループ回数を直接指定, もしくは無限ループできます. 一定間隔でタイマーを起動したい時に便利です.

モバイルに最適化されたコントローラ

再生/一時停止/再開/停止ボタンを搭載したコントローラを設置しました. モバイルでも扱いやすいよう画面下部に固定し, ボタンを大きくしています.

f:id:mizdra:20171130221641p:plain:w300
画面下にコントローラが表示される

カウントダウン時間の単位切り替え

待機時間, 開始までの猶予, 切り上げの値の単位を「秒」および「フレーム」から選択できます.

ポケモンの館のエメループにもフレームを秒に変換するツールが結合されていますが, 実際のカウントダウンで使われる値の単位は「秒」です. これはカウントダウンしたい時間を「秒」と「フレーム」どちらを基準にしたのかの判断が困難になるという問題があります.

本ツールではカウントダウンしたい時間をどちらの単位の値として扱うかを直接していする設計にすることで, どの単位を基準にしたのかを判断しやすくしています. 多くの場合, こちらのほうが直感的でしょう.

f:id:mizdra:20171130221556p:plain:w500
それぞれの入力欄に異なる単位でカウントダウン時間を入力できる

ハイライト

秒の桁が更新される時に「残り時間特大表示」の枠内がハイライト (発光) します. タイミングを合わせたいときに便利です.

youtu.be

Progressive Web Apps

これはほんの先程追加した新機能です! Progressive Web Apps (PWA) とは簡単に言うと, 「モバイル向けWebアプリを (Google PlayApp Storeでインストールするような) ネイティブアプリに近づける」技術です. WebアプリをPWAに対応させると様々な便利な機能が有効になりますが, ここでは本ツールで活用されているほんの一部の機能を紹介します.

(残念ながらPWAはSafariなどの一部のブラウザには対応していません😢 今後SafariでもPWAの対応が進んでいくと思われますが*3, 今すぐにこの機能を利用したい場合は Google Chrome の使用を推奨します👍)

ウェブアプリ マニフェスト

ウェブアプリ マニフェストはアプリを(スマートフォンタブレット, パソコンなどの)ホーム画面に追加したり, ホーム画面からアプリを起動した時の外観をカスタマイズする機能です.

youtu.be

動画では次の機能を確認できます.

  • ホーム画面にWebアプリを追加する
  • ホーム画面からアプリをタップすると初めにスプラッシュ画面が表示され, ロードが終わってからアプリ画面が表示される
  • ブラウザのUI (アドレスバーなど) が隠されている
  • ブラウザとは別のウインドウとして扱われている

キャッシング

ツールを構成するコンテンツは全てローカルにキャッシュされ, 初回以降のアクセスが高速になります. コンテンツの取得はキャッシュを優先的に使用し, 必要なコンテンツだけがネットワークから取得されます.

オフラインで動作可能

なんとEmtimerはWebアプリにも関わらず, オフラインでも動作します!!! これは先程紹介したキャッシングを用いて, オフライン時は全てキャッシュからコンテンツを取得することで実現しています. お使いの端末が機内モードであっても動作します. 飛行機の中でも乱数調整し放題です 💪💪💪

youtu.be

自動更新

PWAは一見するとネイティブアプリのようですが, 実際にはWebアプリであり普通のWebアプリと同じようにアクセスする度にコンテンツは更新されます. ユーザは更新ボタンを押さずに, ツールにアクセスするだけで新機能を利用することができます.

新機能/改善のリクエストについて

ここまで本ツールに搭載されている機能を紹介してきました. もしかしたら🔥熱心な乱数勢🔥*4の方々は「あの機能が欲しいのに無い…!」, 「UIを改善して欲しい!」などと思っているかもしれません. そういった場合は思っていることを開発者に伝えて (フィードバックして) 頂けると今後の開発の支援になります. フィードバックはこの記事のコメントや, 開発者 @mizdra へのリプライ, またはGitHubリポジトリのIssueまでお気軽にどうぞ 😃

今後の開発の予定

Emtimerの開発は🚀今現在も進行中🚀です. 今後の開発の予定はまだ不透明な部分が多いですが, ちょっとだけ紹介します.

  • UI/UXの改善
    • モバイルでより使いやすいようUI/UXを改善します
  • カウントダウン処理の改善
    • カウントダウンがより正確に行われるよう改善します
  • 新しいタイマーの追加
    • 今あるSimpleTimerとは別に, 新しいタイマーを追加します
    • 8秒間のカウントダウン -> 16秒間のカウントダウン -> 17秒間のカウントダウン のように, 異なるカウントダウン時間のタイマーを組み合わせられるプログラマブルなタイマーを追加予定です

おわりに

以上が Pokémon RNG Advent Calendar 2017 1日目「Emtimerの紹介」となります. ここまで読んでくださった方々, ありがとうございました! 😄

それでは今日からクリスマスまでの間, Pokémon RNG Advent Calendar 2017 を楽しんでいきましょう! 良いクリスマスを!

adventar.org

2日目は @Blastoise_X さんの担当です!

参考

*1:http://blogs.adobe.com/japan-conversations/201707adobe-flash-update/

*2:一般的にエメタイマーと呼ばれています

*3:PWAの基礎となっているService WorkerがSafariに実装されることが決まっている (参考: http://trac.webkit.org/changeset/220220/webkit/trunk/Source/WebCore/features.json)

*4:乱数調整に携わる人々のことをそう呼びます

Headless Chrome を使って自動車学校の技能教習の予約が空いたら通知するスクリプトを書いた

最近, 免許を取るために自動車学校に通っていたのですが, 技能講習の予約が一杯で中々教習が進まず困っていました. 通っていた教習所ではオンラインで技能講習の予約を取れるサービスが公開されていたので, また最近話題の Headless Chrome を触ってみたいとも思っていたので, 勢いでスクレイピングして自動車学校の技能教習の予約が空いたら通知するスクリプトを書いてみました.

この記事ではどのようにコードを書いたのか (主に GoogleChrome/puppeteer 周り) を知見を含めて紹介します.

スクレイピング

Headless ChromeAPI を wrap したライブラリである GoogleChrome/puppeteer を使ってスクレイピングしました.

スクレイピングする対象のサービスについて説明しておくと, トップのログインフォームでform認証するとメニューに飛び, そこから技能教習予約ページと予約キャンセルページに飛べる仕様となっています.

# 例
reservation.car.com ... formによるログイン認証ページ
  /menu             ... メニュー
  /reservation      ... 技能教習予約ページ
  /cancel           ... 予約キャンセルページ

セットアップ

スクレイピングを始める前に, まずはpuppeteerのセットアップをします.

const setup = async () => {
  const browser = await puppeteer.launch()    // HeadlessモードでChromeを起動
  const page    = await browser.newPage()     // 新しいタブを開く
  page.setViewport({width: 900, height: 800}) // Viewportの大きさを指定
  return {browser, page}
}

const scrape = async () => {
  const {browser, page} = await setup()
  
  // スクレイピング

  await browser.close() // Chromeを終了する
}

// 開発時は unhandledRejection を subscribe する
process.on('unhandledRejection', (e) => console.log(e))

scrape()

puppeteer のAPIの多くは Promise を返すので async/await を使うと楽に書けます. また, puppeteer内部で発生した unhandledRejection はそのままだとエラーの詳細が出力されないため, 開発時は subscribe しておくと良いでしょう.*1

ちなみに puppeteer.launch() にオプションを渡すと Headless モードをオフにして Chrome を起動したり, ブラウザの操作を一定間隔空けて実行することができます. これによって実際の挙動を画面で, 目で追いやすい速度で確認することができます.

const browser = await puppeteer.launch({
    headless: false, // 画面を表示
    slowMo: 500      // 500ms間隔でブラウザを操作
})

f:id:mizdra:20171001193642g:plain

form認証

page.$eval でクエリにマッチした要素をコールバック関数で受け取ることができるので, これを使ってフォームの値を書き換えます. 値を入力し終えたら page.click を用いて送信ボタンをクリックし, page.waitForNavigation で遷移後のページで load イベントが発火するまで待機します.

const login = async (page) => {
  // 予約サービスに移動
  await page.goto('http://reservation.car.com')

  // Node を取得し, フォームの値を書き換える
  await page.$eval('input[name="username"]', (el) => { el.value = 'mizdra' })
  await page.$eval('input[name="pass"]', (el) => { el.value = 'password' })

  // submit ボタンをクリック
  await page.click('input[type="submit"]')

  // ページの遷移が完了するまで待機
  await page.waitForNavigation({waitUntil: 'load'})
}

予約に空きのある時間帯の取得

page.evaluate は引数で渡した関数をブラウザ上で実行するため, DOM APIにアクセスすることができます. 以下では page.evaluate を使って予約に空きのある時間帯を取得しつつ, 予約ページのスクリーンショットを作成しています.

const screenshot = async (page) => {
  // ページの読み込みが終わるまで待機
  await page.waitForNavigation({waitUntil: 'load'})
  await page.screenshot({path: `screenshots/${Date.now()}.png`})
}

const getFreeClassList = async (page, classList) => {
  const freeClassList = await page.evaluate(() => {
    const buttons = Array.from(document.querySelectorAll('button.free'))
    // value プロパティから日付 (ex. '2017/09/28 16時') を取り出す
    return buttons.map(button => button.value)
  })
  return freeClassList
}

const filterClassListByFree = async (page) => {
  await gotoReservation() // 技能教習予約ページに移動
  await screenshot(page)  // 予約状況をスクリーンショットする

  // 予約に空きのあるクラスのリストを取得
  const freeClassList = await getFreeClassList(page, classList)

  await gotoMenu(page) // メニューに戻る

  // 予約に空きのあるクラスのみを返す
  return classList.filter(cls => freeClassList.includes(cls))
}

ここで注意ですが, デバッグのために page.evaluate に渡した関数の中で console.log を呼び出してもログはブラウザのコンソールに表示されるだけで, Node.js のコンソールには何も出力されません.*2

await page.evaluate(() => {
   // ブラウザのコンソールには出力されるが, Node.js のコンソールには出力されない
  console.log('Hello world!')
})

通常ブラウザのコンソールにログを出力することは無いので次のように console イベントを subscribe して警告を出すようにしておくと良いでしょう.

page.on('console', (...args) => {
  console.warn('Warning: Console API methods is called in browser context.')  
  console.log(...args)
})
await page.evaluate(() => {
  // ブラウザと Node.js の両方のコンソールに出力
  console.log('Hello world!')
})

通知

取得した時間帯を nodemailer/nodemailer を使ってメールで送信します. 今回はメールアカウントに Gmail を使い, SMTP で送信します.

import nodemailer from 'nodemailer'

// 本文のレンダリング関数
const renderHTML = (freeClassList) => `
<p>次の時間帯の予約が空きました. <a href="http://reservation.car.com">ここをクリック</a>して予約を完了して下さい.</p>
<ul>${freeClassList.map(cls => `<li>${cls}</li>`).join('')}</ul>
`

// 予約の空いたクラスをメールで通知する
const sendMail = (freeClassList) => {
  const transporter = nodemailer.createTransport({
    host: 'smtp.gmail.com',
    port: 465,
    secure: true,
    auth: {
      user: '<Gmail アドレス>',
      pass: '<Google アカウントのパスワード>'
    }
  })
  transporter.sendMail({
    from: '"free-driving-class-notification-bot"',
    to: '<通知先のメールアドレス>',
    subject: '技能教習の予約が空きました',
    html: renderHTML(freeClassList)
  }, (err) => {
    if (err) return console.log(err)
  })
}

予約が開くと, 以下のようにメールが飛んできます. これにて完成です 🎉🎉🎉

f:id:mizdra:20171001184759j:plain

その他: 遭遇した問題

puppeteer を弄っているといくつか怪しい挙動を見つけたのでIssue&PRを出しました.

おわりに

*1:http://yosuke-furukawa.hatenablog.com/entry/2016/07/12/103734

*2:これのせいでn時間吸われた

*3:これのせいでn時間吸われた

Dockerを学んだ際の備忘録

概要

お盆の間にDockerについて勉強した序にDockerをどう学んでいったか (どの記事を読んだか, どういう流れで学んだか) を軽く纏めておきます.

目標

以前作ったWebアプリケーション (タイマー) をDockerに載せる.

学習の流れ

実際にDockerizeしてみる

DockerでのNodeアプリ構築で学んだこと | インフラ・ミドルウェア | POSTD で紹介されている手法をベースにDockerizeしてみました.

コンテナの起動

$ git clone https://github.com/mizdra/emtimer.git
$ git checkout 41d2e3e

# imagesのビルド
$ docker-compose build

# プロダクション用
## ソースコードをプロダクション向けにビルドしてhttp-serverでserveする
$ docker-compose -f docker-compose.prod.yml up

# 開発用
## webpack-dev-serverが立ち上がる
## ファイルの変更を検知したら再ビルド&自動リロードされる
$ docker-compose -f docker-compose.yml up

とりあえずやってみましたが設定ファイルを書く際に考えることが多いかなと感じました. まあでもキャッシュを活用しようとすると少し複雑になるのは仕方なさそう. ちょろっとDockerizeするだけならもっと設定ファイルをシンプルにしても良いかもしれません. あと気になったのは COPYUSER の影響を受けないので別途 RUN chown -R app:app $HOME/* する必要があるところ. ただ, この問題については既にPRが立っているのでその内改善されるかも.

ちなみにコンテナ起動時に CMD ["yarn", "run", "prod:start"] の代わりにshellを差し込めば対話的に好きなように作業できます. 便利.

# shellを差し込んで起動
$ docker-compose -f docker-compose.yml run emtimer bash

# 試しにpackageを追加してみる
app@XXXX:~/emtimer$ yarn add moment

# 追加されていることを確認
app@XXXX:~/emtimer$ ls -1 node_modules | grep moment
moment

app@XXXX:~/emtimer$ exit

# imageのnode_modulesはvolumeなのでホストOS上からは見えない
$ ls -1 node_modules | grep moment

# package.jsonやyarn.lockはbind mountsの機能により更新されているので
# yarn installで追加されたパッケージをインストールできる
$ yarn install
$ ls -1 node_modules | grep moment
moment

多くの場合, 開発時はdocker上で開発サーバを立ててホストOS上からエディタでソースコードを編集するような形を取ることになります. その際にホストOS上に node_modules の中身が存在しないとエディタのプラグイン(eslintなど)がエラーを吐くので, 適時ホストOS上でも yarn install すると良いと思います. 多分… *1

おわりに

ヨッシャDockerやるぞという気持ちが湧いてきたので1からDocker学んでみました. 2日くらい掛かりましたがそれなりに勉強になったので満足度高めでした.

おわり.

*1:知見が殆どないのでこれで合っているか分からず… もっと良い解決法あれば教えて頂けると :pray:

2016年を振り返って

"今年"は2016年, "来年"は2017年のことを指します.


去年同様, 今年も1年の振り返りをします. プログラミングの話題が中心ですが, 割りと雑です.

今年も年が明ける前に投稿出来なかった. 残念.

1月, 2月

Pro Gitを読みました. それ以前にもGitの入門資料に目を通し, 軽くgitに触れていたので(大変でしたが)6章まで読み切ることができました. Pro GitはGitの仕組みを紹介した上でコマンドの使い方等を解説しています. 自分は仕組みや内部の実装を知ってからツールを使うという学習方法が好きだったので, この資料は自分にとって非常に合っていました.

それとPokeSugarという「ポケモンを一行で表現する構文」を作りました. 作ったは良いけどまだどのプロジェクトでも使用していません. 機運があればこれを使って何か作ろうかな…

3月

VSCodeが1.0になったので本格的にVSCodeを使い始めました. VSCode便利ですよね. 僕は大好きです. 皆さんも使いましょう.

ポケモン関連では 反動ダメージに関する調査 を行いました. 端数処理にちょっとだけ詳しくなりました.

4月

某多摩の大学に入学し, そこでMMAという技術系サークルに入部します. 総合格闘技ではありません. この頃は忙しかったので特筆することはありません. 敢えて書くとしたら, MMAに入部したことで普通の大学生活を送ることが出来なくなった, ということでしょうか. 皆さんもサークル選びは気をつけましょう.

5月, 6月

大学のコンピュータ上に 開発環境を構築 していました. make やら ./configure やらをしてちょっとだけCLIに詳しくなりました. ビルドしんどい.

7月, 8月

Pokémon GOです. 大宮, 池袋, 新宿, 渋谷, 調布など色々な所に行きました. 人が沢山いる公園でプレイすると一体感があって中々楽しかったです. 今の公園は最盛期と比べると寂しいですね. でも, 何だかんだ言って続けてますが.

8月は西日本に住んでいるフォロワーさん2人とお会いしてきました. 貴重な体験でした.

9月

この頃は色々あって疲れていて少し精神が不安定でした. そのせいか mizdra/shiny-text という訳のわからないアプリケーションを作るなどしていました.

なんもわからん.

10月, 11月

Dentoo.LT #15, Dentoo.LT #15.5 に参加しました. Dentoo.LT #15.5では登壇して発表をしました. この辺のことは MMAに入って1年でやったこと - mizdra's blog にまとめてあります.

www.slideshare.net

12月

Advent Calendarの月です. 今まで僕はAdvent Calendarを見るだけで参加すらしたことが無かったのですが, 今年は2つのAdvent Calendarに参加し, その内1つは主催を担当させて頂きました. 前者が MMA Advent Calendar, 後者が Pokémon RNG Advent Calendar です. Pokémon RNG Advent Calendarに関して, 初め僕は「マイナーな分野だしどうせ全日埋まらないだろうなあ」という気持ちでこのAdvent Calendarを開催したのですが, 驚くべきことに全日埋まり, 完走することができました. めでたい!!!

12月後半はReact+ReduxでSMの乱数調整補助ツールを作っていました. これは現在も製作中で, 暫くしたら公開できるかと思います. どうぞお楽しみに.

GitHubを振り返る

去年

f:id:mizdra:20160103192801p:plain

今年

f:id:mizdra:20170101003302p:plain

良い感じに芝を生やせました. この調子で来年も頑張っていきます.

おわりに

今年も色々なことを学びました. 2016年で個人的に最も大きかった出来事は大学への入学, そして技術系サークルのMMAへの入部です. 技術系の人々が多くいる環境に身を置くと何もしなくても(聞いたり読んだりはしているが)様々な情報が入ってくるというのは本当に良いですね. 先程も書きましたが, サークル関係でやったことは MMAに入って1年でやったこと - mizdra's blog にまとめてあります.

プログラミング以外で自分が成長したと感じたこととして, メンタル面があります. 今年に入ってエンジニアとしての心持ち, 精神論, 考え方など, メンタルに関する記事を読むようになりました. 去年まではあまり興味は無かったのですが, 技術系の人々が多くいる環境にいるために, そうした知識はどうしても必要になってきます.

それとPokémon RNG Advent Calendarを開催できたのは良い体験でした. お陰様で完走することができました. ありがとうございます 🙏

来年はReact+Redux, Go*1, ソフトウェアのテストについて学びたいですね. やっていきましょう.

*1:去年もGoやりたいって言ってた気がする…

Pokémon RNG Advent Calendar の主催をした話

Pokémon RNG Advent Calendar 2016 24日目の記事です.

www.adventar.org

今日は一部の界隈ではクリスマス・イブと呼ばれる日だそうですが, 僕はゆゆ式のニコ生一挙放送が行われる日だと認識しています. この記事も画面左半分でゆゆ式を視聴しながら書いている訳です.

さて, 駄文はこれくらいにしてタイトル通り「Pokémon RNG Advent Calendar の主催をした話」について書いていこうと思います.

先に断っておきますが, これはポエムです.

開催をしようとおもったきっかけ

そもそも僕がPokémon RNG Advent Calendarを開催しようと思ったのは以下のツイートを見たのがきっかけです.

当時僕がこのツイートを見た時, 「乱数調整Advent Calendarか. 需要あるのかな〜?」などと考えていた気がします. その時点では開催するつもりは無かったのですが, 後ほど述べる諸所の理由からAdvent Calendarを開催することを決めました.

背景

ここで, 僕が今まで乱数調整にどう関わってきたかについて書いていこうと思います. 僕が乱数調整に初めて関わったのが2010年くらいで, PHSを使ってHGSSで色乱数(調整)をしていました . その後暫くHGSSやBW1, BW2で高個体値ポケモンを生成したり, 色違いポケモンを生成したりと色々な乱数調整をしていました.

時は2013年, 当時BWのレーティングバトルでは地球投げを覚えた輝石ラッキーが人気でした. ラッキーに地球投げを覚えさせるには第三世代の教え技を利用するしかないのですが, 僕はEmを持っていなかったので, FRLGを使って乱数調整をするしかありませんでした. 補足ですが, Emでは乱数の仕組みがほぼ解析され, 乱数調整が容易に出来ることが判明しています. 一方で, FRLGは初期Seedの決定方法が解明されていないため(再現性はとれますが, 計算式が不明 *1 ), 乱数調整を行うのは少々困難です. その為, FRLG乱数調整界隈の人口は非常に少なく, FRLG用の乱数調整補助ツールも殆ど出回っていないので, 既存のツールでは出来ない乱数調整をするにはツールを自作する必要がありました. 幸いなことに, 僕はそれ以前からプログラミングを学習しており, 簡単なプログラムを作成する程度のスキルを持っていたので, ネット上の解析情報を頼りにFRLGの乱数調整を作成することができました.

その後も乱数調整補助ツールを幾つか作るなどして今に至るわけですが, 「この乱数調整補助ツール」というのは小さな界隈でしか通じないキーワードで, 非常に奥が深く, 乱数調整という言葉を知らない人に僕がやっていることを上手く伝えられないという問題がありました. 乱数調整をいう言葉を一言で説明するのは非常に難しく, 乱数の種類や乱数生成法, ゲーム内でどう乱数が扱われているかという乱数調整の原理を全て押さえてから, 乱数調整について説明しなければなりません. しかし, 驚くべきことに乱数調整をこうした原理を含めて解説されている (日本語の) 記事は今までこの世の中に1つも無かったのです. こうした背景から, 乱数調整という言葉を初めて聞いた人向けに乱数調整について解説する「乱数調整 入門」を書こうと思い, Pokémon RNG Advent Calendarの開催を決意した訳です.

mizdra.hatenablog.com

勿論, Pokémon RNG Advent Calendarを開催しようと思った理由に「乱数調整の楽しさについて純粋に皆に知って欲しいから」というのもあります. ですから, 1日目の記事は乱数調整の原理も説明しつつ, 乱数調整の楽しさも同時に伝えるということを意識して書いています.

開催した後の話

初め僕は「マイナーな分野だしどうせ全日埋まらないだろうなあ」という気持ちでこのAdvent Calendarを開催したのですが, なんと12/24の時点で全日埋まっております. めでたい!!! 🎉🎉🎉

SM乱数調整が盛り上がったおかげですね. ありがたい 🙏

また, 僕がLTイベントで登壇した際にこのAdvent Calendarの宣伝をさせて頂きました. LTイベントでは「乱数調整と謎乱数の話」, 「乱数調整 入門」について話しました.

www.slideshare.net

www.slideshare.net

イベントに同席された方に話を伺うと, 「乱数調整に興味はあるが, やり方がよく分からない」「最新作での乱数調整はできるのか」などの質問を頂くことがあり, それなりにこういう話をする需要はあるようです.

今後LTで話す乱数調整ネタですが, SM乱数調整関連で何か話せればいいなあ程度に考えています. お楽しみに.

おわりに

書いた文章を読み返すと, 文脈もクソもないし, 結論もアレで色々終わっています. まさにポエムという感じですね.

まあ, これはポエムだと一番初めに宣言しているので許されたい.

おわり.

明日は @quan_dra さんが担当です!

*1:当時は全くの不明でしたが, 現在ではある程度解析されています

MMAに入って1年でやったこと

MMAに入って1年でやったこと

MMA Advent Calendar 2016 23日目の記事です.

www.adventar.org

16入学の1年生のmizdraです. MMA*1に入ってやったことを書こうと思ってたんですが先を越されました. 他のテーマを考えてなかったので, 出来るだけ内容が被らないように書くということでやっていきます.

新入生講習会

MMAではここ数年, 新入生部員に対しサークルの各設備の紹介やプログラミング講習などを行う「新入生講習会」が開催されており, 僕は新入部員として参加しました. サークルのメーリングリストWiki, サーバ等の利用方法についてなど様々な講習を受け, 「なるほど〜」などと呟いていた記憶があります. 受講することでサークルの設備の基本的な使い方が理解できるようになるので, 何も分からず入った新入生にとっては非常にありがたい取り組みです. ちなみにこの新入生講習会の所謂講師に当たる人は主に2, 3年生から選ばれるのですが, 僕も後数ヶ月経てば2年生となり*2, 何かしらの講習会の担当することになる訳です. 何をやるのかな.

CTF勉強会 & SECCON 2016 オンライン予選

部の有志が集まって11月から毎週1回, CTF勉強会というものを開催しています. 勉強会というものの, 実際には各自が常設CTFで解いてきた問題をその場で発表し, 知見を共有するということをやっています.

そんな感じでCTF勉強会に参加していると, 11月の下旬あたりに先輩に「SECCONというCTFの予選が12月にあって〜」という話を持ちかけられて, その場にいた1年生3人( @matsurika_1226 @hatuyuki0224 @mizdra )でチーム「icefield」を結成し, SECCONに参加することになりました. 結成した後に @swkfn もチームに加わって最終的には計4人になりました. ちなみにこの「icefield」というチームの名前の由来についてはリーダーの @matsurika_1226 からいずれ語られるはずなのでその時までお預けということにします.

参加を決めたのが数週間前で11月は多忙だったというのもあって, 殆ど対策をせずに大会に臨んだのですが, 獲得点数200ptで433位(同率)と悲しい結果に終わりました. 得点の内訳は以下の通りです.

問題 ジャンル 得点
Vigenere Crypto 100
VoIP Forensics 100

この内僕はVigenereを解きました.

# Vigenere

k: ????????????
p: SECCON{???????????????????????????????????}
c: LMIG}RPEDOEEWKJIQIWKJWMNDTSR}TFVUFWYOCBAJBQ

k=key, p=plain, c=cipher, >md5(p)=f528a6ab914c1ecf856a1d93103948fe

|ABCDEFGHIJKLMNOPQRSTUVWXYZ{}
-+----------------------------
A|ABCDEFGHIJKLMNOPQRSTUVWXYZ{}
B|BCDEFGHIJKLMNOPQRSTUVWXYZ{}A
C|CDEFGHIJKLMNOPQRSTUVWXYZ{}AB
D|DEFGHIJKLMNOPQRSTUVWXYZ{}ABC
E|EFGHIJKLMNOPQRSTUVWXYZ{}ABCD
F|FGHIJKLMNOPQRSTUVWXYZ{}ABCDE
G|GHIJKLMNOPQRSTUVWXYZ{}ABCDEF
H|HIJKLMNOPQRSTUVWXYZ{}ABCDEFG
I|IJKLMNOPQRSTUVWXYZ{}ABCDEFGH
J|JKLMNOPQRSTUVWXYZ{}ABCDEFGHI
K|KLMNOPQRSTUVWXYZ{}ABCDEFGHIJ
L|LMNOPQRSTUVWXYZ{}ABCDEFGHIJK
M|MNOPQRSTUVWXYZ{}ABCDEFGHIJKL
N|NOPQRSTUVWXYZ{}ABCDEFGHIJKLM
O|OPQRSTUVWXYZ{}ABCDEFGHIJKLMN
P|PQRSTUVWXYZ{}ABCDEFGHIJKLMNO
Q|QRSTUVWXYZ{}ABCDEFGHIJKLMNOP
R|RSTUVWXYZ{}ABCDEFGHIJKLMNOPQ
S|STUVWXYZ{}ABCDEFGHIJKLMNOPQR
T|TUVWXYZ{}ABCDEFGHIJKLMNOPQRS
U|UVWXYZ{}ABCDEFGHIJKLMNOPQRST
V|VWXYZ{}ABCDEFGHIJKLMNOPQRSTU
W|WXYZ{}ABCDEFGHIJKLMNOPQRSTUV
X|XYZ{}ABCDEFGHIJKLMNOPQRSTUVW
Y|YZ{}ABCDEFGHIJKLMNOPQRSTUVWX
Z|Z{}ABCDEFGHIJKLMNOPQRSTUVWXY
{|{}ABCDEFGHIJKLMNOPQRSTUVWXYZ
}|}ABCDEFGHIJKLMNOPQRSTUVWXYZ{

Vigenere暗号ですね. 平文の一部と暗号文と変換表が与えられているし鍵の一部も分かりそうだなと思ったので, 手作業で鍵の一部を求めました.

求めた鍵を用いて復号すると次のような結果が得られました.

k: VIGENER?????
p: SECCON{?????BCDEDEF?????KLMNOPQ?????VWXYYZ}
c: LMIG}RPEDOEEWKJIQIWKJWMNDTSR}TFVUFWYOCBAJBQ

平文をよく見てみると, 一部を除いて大体アルファベット順に文字が並んでいることが分かります. そこで鍵と暗号文を与えると平文に復号するスクリプトを作成し, 平文がアルファベット順になるような鍵を適当に推測してそれに投げるということをやってみました. 平文がアルファベット順にならなかった場合は鍵を修正して再実行といった感じでやると SECCON{ABABABCDEDEFGHIJJKLMNOPQRSTTUVWXYYZ} と良い感じの平文が得られたので勝ちです💪

他にも, 「basiq」というタイトルのWeb問題に対して「Webなら何だかんだで結構触ってるし解けるでしょ〜」という軽い気持ちで臨んだのですが, 見事撃沈しました. Web問題といっても, HTML, JavaScript, 認証, DBであったりと様々な分野の知識が要求されるので, どれか1つ知っているくらいでは解くのが困難という印象を受けました. まだまだ勉強していく必要がありそうです. やっていきましょう.

Dentoo.LT

MMAが主催するLTイベントで, 僕は #15 から参加しています. これに参加するまでLTイベントに参加することが全く無く, はじめは緊張しながら会場に向かったのですが, 実際に参加してみると知見になる話や面白い話が聞けてとても楽しかったです. Dentoo.LTでは生放送を行っており, 家でもお茶を飲みながらのんびりとLTを視聴することができます. 僕は会場の設営に加え, 生放送をするための機材の準備などのお手伝いをさせて頂きました. 普段は触ることのないような音響用の機材や配信用の機材をアレコレしたり, 配信の準備の様子を眺めたりと貴重な体験ができました.

#15.5 では飛び入り枠でLTをしました. ただ, 与えられた発表時間に対して大幅にオーバーしてしまったり, スライドが雑であったり色々反省すべき点がありました. 申し訳ございません🙇

何事も準備が大切です. 次回はちゃんとします.

www.slideshare.net

MCCMMANCC2016

MCC, MMA, NCCのメンバーが集まって, LTやら交流会を行うイベントです *3. この謎の3文字のアルファベットの羅列ですがこれはとある大学のサークル名を表しており, それぞれ東京農工大学のMicro Computer Club, 電気通信大学のMicrocomputer Making Association, 明治大学のNakano Computer Clubです.

会場は明治大学の中野キャンパスで, 多摩の某大学とは違って綺麗で素敵なキャンパスだなと思いながら指定された教室に向かいました. イベントは大きく分けて以下のパートから構成されます.

  • レクレーション
  • LT
  • 交流会
  • お菓子パーティー

レクレーションは @FMS_Cat さんによるGSLSのハンズオンでした. 想像していたよりも簡単にグラフィックが扱えてすごい, といった感じです. それとお菓子が無限に食べられるのはアドです.

当日は他大の方と大学生活や技術的なことだったり色々な話をしました. 特に同学年の方(@ykun03 @sasamijp)ともお話をすることができて, 非常に良い体験が出来ました.

ちなみにですが, このイベントでも飛び入りLTをさせて頂きました. 10分用のスライドを4分の枠で発表したので色々なことを端折ることになってしまったのですが, 今度は時間内に終えられたのでまあ良しとしましょう. 今度LTするときは飛び入りではなくきちんと事前に通常枠を申請して余裕を持って話したいですね.

www.slideshare.net

最後に

他にもやったことはあるのですが, 書いてて疲れてきたのでこの辺でということで.

明日は @benevolent0505 さんによる「聖夜の夜にDentoo.LT」です.

*1:Mixed Martial Arts (総合格闘技) ではなく多摩の某大学のサークル Microcomputer Making Association のことです

*2:進級できない人も世の中には存在するのですが, 多摩の某大学では1年生->2年生であれば何事もなく進級できることが知られています

*3:イベント名MCCMMANCC2016で合ってるのかな...?