Object.entriesの戻り値の型を厳密にするためにNegated typesが欲しい

Object.entries() - JavaScript | MDN

Object.entries は、オブジェクトに含まれる key と value の組み合わせを配列にして返してくれる関数で、 Javascript でコードを書いてるとお世話になることも多いと思います。
しかし、これを Typescript で使おうとすると、型が微妙になってしまうという問題点があります:

type Hoge = {
    a: string;
    b: number;
};

function f(x: Hoge) {
    const entries = Object.entries(x);
    // entries の型は [string, string | number][]
    for (const [key, value] of entries) {
        // 処理
    }
}

これを解決するために、以下のような手法を使うことができます:

zenn.dev

type Entries<T> = (keyof T extends infer U
    ? U extends keyof T
        ? [U, T[U]]
        : never
    : never)[];

function getEntries<T extends Record<string, unknown>>(obj: T): Entries<T> {
    return Object.entries(obj) as Entries<T>;
}

type Hoge = {
    a: string;
    b: number;
};

function f(x: Hoge) {
    const entries = getEntries(x);
    for (const [key, value] of entries) {
        if (key === 'a') {
            console.log(value.toUpperCase());
        } else if (key === 'b') {
            console.log(value + 1);
        } else {
            // value は never
            throw new Error('should not get here.');
        }
    }
}

これを使えば厳密に型付けされた entries を使うことができてハッピー……

そう思いましたか? 実はこれ、思わぬ落とし穴があります。
Hoge の定義を type から interface に変更してみましょう:

type Entries<T> = (keyof T extends infer U
    ? U extends keyof T
        ? [U, T[U]]
        : never
    : never)[];

function getEntries<T extends Record<string, unknown>>(obj: T): Entries<T> {
    return Object.entries(obj) as Entries<T>;
}

interface Hoge {
    a: string;
    b: number;
}

function f(x: Hoge) {
    // const entries = getEntries(x);  // コンパイル通らない!
    const entries = Object.entries(x);  // これなら通るが、 entries の型は [string, any][]
    for (const [key, value] of entries) {
        // 処理
    }
}

何故か先程の getEntries ではコンパイルが通らなくなってしまいました。
また、 Object.entries の型も [string, any][] に変わってしまいました。

何故でしょう?

実はこれ、 type ではなく interface の方が望ましい挙動なのです。

www.totaltypescript.com

このライブラリの「Object.keys / Object.entries」の欄に、その説明があります。

TypeScript is a structural typing system. One of the effects of this is that TypeScript can't always guarantee that your object types don't contain excess properties:

type Func = () => {
  id: string
}

const func: Func = () => {
  return {
    id: '123',
    // No error on an excess property!
    name: 'Hello!',
  }
}

So, the only reasonable type for Object.keys to return is Array<string>.

ざっくり翻訳すると「TypeScript は構造的型付けを採用しているので、余剰のプロパティがオブジェクトに含まれていないことを保証できない。 だから Object.keys の戻り地の型で合理的なのは Array<string> のみだ」となります。

これは Object.entries に関しても同様のことが言えます:

type Entries<T> = (keyof T extends infer U
    ? U extends keyof T
        ? [U, T[U]]
        : never
    : never)[];

function getEntries<T extends Record<string, unknown>>(obj: T): Entries<T> {
    return Object.entries(obj) as Entries<T>;
}

type Hoge = {
    a: string;
    b: number;
};

function f(x: Hoge) {
    const entries = getEntries(x);
    for (const [key, value] of entries) {
        if (key === 'a') {
            console.log(value.toUpperCase());
        } else if (key === 'b') {
            console.log(value + 1);
        } else {
            // value は never
            throw new Error('should not get here.');
        }
    }
}

// 余剰のプロパティが含まれた変数
const x = { a: 'hoge', b: 42, c: { x: 13 }, };
// これは合法な呼び出し(結果として never の部分に到達して例外が投げられる)
f(x);

つまり、先ほど紹介した記事の getEntries は、危険をはらんだコードであると言えます。
この危険を回避するためには interface を使うといいので、この記事から得られる教訓として、

「TypeScript で型を定義する際は、可能なら interface を使うこと!」

が挙げられます。
こちらも参照してください:

teratail.com



さて、とはいえ、厳密な型付けが欲しいのも確かです。 どうにかならないものでしょうか?

この場合、規定のプロパティには定められた型を与えて、余剰のプロパティに対しては unknown を推論することで何とかする、というのが、おそらく最もスマートな解決策だと思います:

type Entries<T> = /* 定義をここに書く */;

// スマートな型定義ができるなら Record である必要はない
function getEntries<T extends Object>(obj: T): Entries<T> {
    return Object.entries(obj) as Entries<T>;
}

interface Hoge {
    a: string;
    b: number;
}

function f(x: Hoge) {
    const entries = getEntries(x);
    for (const [key, value] of entries) {
        if (key === 'a') {
            console.log(value.toUpperCase());
        } else if (key === 'b') {
            console.log(value + 1);
        } else {
            // value は unknown
            console.log(value);
        }
    }
}

結論から言います。 この Entries<T> は、上手く書くことができません。
というのも、現行の TypeScript では、「 string から ある特定のリテラル型を除いた型」を定義することが出来ないからです:

type T = Exclude<string, 'a' | 'b'>;
// T は string から 'a' や 'b' を除いた型…ではない。 string になる

これを解決するために、 Negated types という機能が提案されています:

github.com

実装もあるようです:

github.com

残念ながら実装の方は merge されることなく close されてしまいましたが、あると嬉しいので、いつか実装されるといいなあと思っています。

追記

参考までに、 Negated types を用いた Entries<T> の実装を書いておきます:

type Entries<T> = (
    (keyof T extends infer U
        ? U extends keyof T
            ? [U, T[U]]
            : never
        : never)
    | [string & not keyof T, unknown]
)[];

git 管理している dotfiles をインストールするスクリプトを書いてみた

gintenlabo.hatenablog.com

の続き。


課題だったインストールスクリプトを書いてみました。

#!/usr/bin/env bash
# file: install-script.bash
set -ueo pipefail
cd "$(dirname "$0")"

CMDNAME=$(basename "$0")
print_usage() {
  cat - << EOF
usage: ${CMDNAME} (-n|-x) [options...]
    -n
        Executes dry run mode; don't actually do anything, just show what will be done.
    -x
        Executes install. This option must be specified if you want to install.
    -S <suffix>
        Specify backup suffix.
        If not given, BACKUP_SUFFIX is used; neither is given, '~' is used.
        This argument cannot be empty string.
EOF
}

MODE=
BACKUP_SUFFIX=${BACKUP_SUFFIX:-\~}

quote_each_args() {
  for i in $(seq 1 $#); do
    if [[ $i -lt $# ]]; then
      printf '%q ' "${!i}"
    else
      printf '%q' "${!i}"
    fi
  done
}
print_dry_run_message() {
  echo -e "will exec '$*'"
}
print_executing_message() {
  echo -e "executing '$*'..."
}
run() {
  if [[ "${MODE}" == 'dry-run' ]]; then
    print_dry_run_message "$(quote_each_args "$@")"
  else
    print_executing_message "$(quote_each_args "$@")"
    "$@"
    echo 'done.'
  fi
}

while getopts 'nxoS:u:m:' opt; do
  case $opt in
    n) MODE='dry-run' ;;
    x) MODE='execute' ;;
    S) BACKUP_SUFFIX="$OPTARG" ;;
    *) print_usage >&2
       exit 1 ;;
  esac
done
if [[ -z "${MODE}" || -z "${BACKUP_SUFFIX}" ]]; then
  print_usage >&2
  exit 1
fi

# init submodules
run git submodule update --init

# create symbolic links
echo
./install-script-tools/ls-linking-files.bash | while read -r file; do
  run ln -srvb -S "${BACKUP_SUFFIX}" -T "${file}" "${HOME}/.${file}"
done

# ここに残った初期化処理を書く( .gitconfig.local ファイルの作成とか vim の設定とか)
# 今回は省略
#!/usr/bin/env bash
# file: install-script-tools/ls-linking-files.bash
set -ueo pipefail

cd "$(dirname "$0")/.." # move to project root

LINK_IGNORE=${LINK_IGNORE:-.linkignore}

CMDNAME=$(basename "$0")
print_usage() {
  cat - << EOF
usage: ${CMDNAME} [options...]
    -v
        Verbose mode; print tracked files and ignored files.
    -h
        Print this message and exit.
EOF
}

VERBOSE=

while getopts 'vh' opt; do
  case $opt in
    v) VERBOSE='on' ;;
    h) print_usage
       exit ;;
    *) print_usage >&2
       exit 1 ;;
  esac
done

remove_directory_contents() {
  cat - | sed 's/\/.*//g' | sort -u
}

TRACKED_FILES=$(git ls-files -c | grep -v '^\.')
if [[ -n "${VERBOSE}" ]]; then
  echo "tracked files:" >&2
  echo -e "${TRACKED_FILES}\n" >&2
fi

IGNORED_FILES=$(git ls-files -ic --exclude-from="${LINK_IGNORE}")
if [[ -n "${VERBOSE}" ]]; then
  echo "ignored files:" >&2
  echo -e "${IGNORED_FILES}\n" >&2
fi

echo "${TRACKED_FILES}" | grep -vxFf <(echo "${IGNORED_FILES}") | remove_directory_contents
# file: .linkignore
# install-script でリンクさせたくないファイル/ディレクトリをここに置く
# 形式は .gitignore と一緒
# サブディレクトリ内のファイルは関係ないので、誤解なきよう原則として / 始まりで指定すること
/README*
/install-script*


使い方:

$ ./install-script.bash -n

と入力すると、実際に実行されるコマンドが表示される(実行はされない)。
問題ないようなら

$ ./install-script.bash -x

で実行。
この際、リンクされるファイルが既に存在していた場合にバックアップが(~/.zshrc~のような名前で)作られるので、問題なくインストール出来ているようなら削除する。
また、バックアップから復元したい場合のために、以下のようなスクリプトも書いた:

#!/usr/bin/env bash
# file: install-script-tools/restore-dotfiles-from-backup.bash
set -ueo pipefail
WORKDIR=$(pwd)
cd "$(dirname "$0")"

CMDNAME=$(basename "$0")
print_usage() {
  cat - << EOF
usage: ${CMDNAME} (-n|-x) [options...] [files...]
    -n
        Executes dry run mode; don't actually do anything, just show what will be done.
    -x
        Executes restoration. This option must be specified if you want to restore.
    -d
        Deletes given file if no backup found.
    -S <suffix>
        Specify backup suffix.
        If not given, BACKUP_SUFFIX is used; neither is given, '~' is used.
        This argument cannot be empty string.
    files
        Specify file paths to restore.
        If not given, files would be linked by install-script and ~/.gitconfig.local is restored.
EOF
}

MODE=
DELETE=
BACKUP_SUFFIX=${BACKUP_SUFFIX:-\~}

quote_each_args() {
  for i in $(seq 1 $#); do
    if [[ $i -lt $# ]]; then
      printf '%q ' "${!i}"
    else
      printf '%q' "${!i}"
    fi
  done
}
print_dry_run_message() {
  echo -e "will exec '$*'"
}
print_executing_message() {
  echo -e "executing '$*'..."
}
run() {
  if [[ "${MODE}" == 'dry-run' ]]; then
    print_dry_run_message "$(quote_each_args "$@")"
  else
    print_executing_message "$(quote_each_args "$@")"
    "$@"
    echo 'done.'
  fi
}
restore() {
  for file in "$@"; do
    local backup="${file}${BACKUP_SUFFIX}"
    if [[ -e "${backup}" ]]; then
      run rm -f "${file}"
      run mv -T "${backup}" "${file}"
    elif [[ -n "${DELETE}" ]]; then
      run rm -f "${file}"
    fi
  done
}

while getopts 'nxS:d' opt; do
  case $opt in
    n) MODE='dry-run' ;;
    x) MODE='execute' ;;
    S) BACKUP_SUFFIX="$OPTARG" ;;
    d) DELETE='on' ;;
    *) print_usage >&2
       exit 1 ;;
  esac
done
if [[ -z "${MODE}" || -z "${BACKUP_SUFFIX}" ]]; then
  print_usage >&2
  exit 1
fi

shift $((OPTIND - 1))

if [[ $# -eq 0 ]]; then
  ./ls-linking-files.bash | while read -r filename; do
    restore "${HOME}/.${filename}"
  done
else
  (cd "${WORKDIR}" && restore "$@")
fi
$ ./install-script-tools/restore-dotfiles-from-backup.bash -x ~/.zshrc

のようにして復元できる(ファイル名指定を省略した場合はリンクされるもの全部が対象になる)。


注意点として、 Mac だと ln コマンドのオプションが違うので、このままでは使えない。
後で Mac 版も書くつもり(blog で公開するかどうかは置いといて)。


実際のコードはこちら:

github.com


参考にして頂ければ幸い。

WSL Ubuntu に Oh my zsh と asdf をインストールしつつ、 .zshrc とかを git で管理するようにする

社内 Advent Calendar 向けに書いた記事ですが、社内のみに公開しておくのも勿体ないので全体公開するついでに blog にもリンク張っておきます。

gist.github.com

また、筆者が使っている dotfiles の GitHub repository も共有しておきます:
github.com

master は内容が古いので wsl-ubuntu ブランチが参考になるかと思います。

参考にして頂ければ幸い。

TypeScript で Haskell の comparing を実装してみた

https://hackage.haskell.org/package/base-4.19.0.0/docs/Data-Ord.html#v:comparing

たぶん既出(調べるの面倒で調べていない


実装:

function compareAsc<T>(x: T, y: T): number {
  return x < y ? -1 : y < x ? 1 : 0;
}
function compareDesc<T>(x: T, y: T): number {
  return -compareAsc(x, y);
}

function comparing<T, R>(
  f: (x: T) => R,
  compare: (x: R, y: R) => number = compareAsc,
): (x: T, y: T) => number {
  return (x, y) => compare(f(x), f(y));
}

function comparingWith<Obj, Key extends keyof Obj>(
  key: Key,
  compare: (x: Obj[Key], y: Obj[Key]) => number = compareAsc,
): (x: Obj, y: Obj) => number {
    return comparing((obj: Obj) => obj[key], compare);
}


使い方:

const arr = [
  {a: 1, b: 2},
  {a: -1, b: 4},
  {a: 0, b: 6},
  {a: 23, b: 8},
];

// obj.a でソート
console.log([...arr].sort(comparing(({a}) => a)));
// この場合はcomparingWithで楽に書ける
console.log([...arr].sort(comparingWith('a')));

// b で逆順
console.log([...arr].sort(comparingWith('b', compareDesc)));
// a + b でソート
console.log([...arr].sort(comparing(({ a, b }) => a + b)));


多分だけど既存のパッケージあります。 知ってたら教えてください。

TypeScript で API 呼び出し結果をキャッシュするクラスを作ってみた

実装:

class CachedAsyncStore<T, Key = string> {
  private promiseMap: Map<Key, Promise<T>> = new Map();
  private fn: (key: Key) => Promise<T>;

  constructor(fn: (key: Key) => Promise<T>) {
    this.fn = fn;
  }

  get(key: Key): Promise<T> {
    const { promiseMap } = this;
    const cachedPromise = promiseMap.get(key);
    if (cachedPromise != null) {
      return cachedPromise;
    }
    const newPromise = this.fn(key);
    promiseMap.set(key, newPromise);
    return newPromise;
  }
  deleteCache(key: Key): boolean {
    return this.promiseMap.delete(key);
  }

  async getOrRetry(key: Key): Promise<T> {
    const { promiseMap } = this;
    for (;;) {
      const cachedPromise = promiseMap.get(key)
      if (cachedPromise == null) {
        break;
      }
      try {
        const result = await cachedPromise;
        return result;
      } catch (e) {
        // retry
        if (cachedPromise === promiseMap.get(key)) {
          promiseMap.delete(key);
        }
      }
    }
    const newPromise = this.fn(key);
    promiseMap.set(key, newPromise);
    return newPromise;
  }

  clearCache(): void {
    this.promiseMap = new Map();
  }
}


使い方:

// コンストラクタに取得関数を渡す
const store = new CachedAsyncStore<{ value: string }>(async (key) => {
  console.log(`API called: key: ${key}`);
  await new Promise<void>(resolve => setTimeout(resolve, 1000));
  if (key === 'error') {
    throw new Error('error!');
  }
  return {
    value: key,
  }
});

(async () => {
  // get で API を呼び出す
  console.log(await store.get('hoge'));
  // 既に呼び出されたことがあった場合はキャッシュが使われる
  console.log(await store.get('hoge'));

  // 並列で呼び出しても API は1回のみ呼ばれる
  console.log(await Promise.all([
    store.get('fuga'),
    store.get('fuga'),
    store.get('fuga'),
  ]));

  // エラーの場合
  try {
    await store.get('error');
  } catch (e: any) {
    console.error(e.message);
  }
  // get だと API 呼び出しは再試行されずにエラーになる
  try {
    await store.get('error');
  } catch (e: any) {
    console.error(e.message);
  }

  // エラーなら再試行したい場合は getOrRetry を使う
  // 並列で呼び出した場合でも直列化されるオマケつき
  console.log(await Promise.allSettled([
    store.getOrRetry('error'),
    store.getOrRetry('error'),
    store.getOrRetry('error'),
    store.getOrRetry('error'),
  ]));
})();


動機:

// 素朴な実装の場合
class SimpleCachedAsyncStore<T, Key = string> {
  private resultMap: Map<Key, T> = new Map();
  private fn: (key: Key) => Promise<T>;

  constructor(fn: (key: Key) => Promise<T>) {
    this.fn = fn;
  }

  async get(key: Key): Promise<T> {
    const { resultMap } = this;
    const cachedValue = resultMap.get(key);
    if (cachedValue !== undefined) {
      return cachedValue;
    }
    const newValue = await this.fn(key);
    resultMap.set(key, newValue);
    return newValue;
  }
  deleteCache(key: Key): boolean {
    return this.resultMap.delete(key);
  }

  clearCache(): void {
    this.resultMap = new Map();
  }
}

const store = new SimpleCachedAsyncStore<{ value: string }>(async (key) => {
  console.log(`API called: key: ${key}`);
  await new Promise<void>(resolve => setTimeout(resolve, 1000));
  if (key === 'error') {
    throw new Error('error!');
  }
  return {
    value: key,
  }
});

(async () => {
  // get で API を呼び出す
  console.log(await store.get('hoge'));
  // 直列なら問題ない(キャッシュされた値が使われる)
  console.log(await store.get('hoge'));

  // 並列で呼び出した場合に API が複数回実行されてしまう
  console.log(await Promise.all([
    store.get('fuga'),
    store.get('fuga'),
    store.get('fuga'),
  ]));

  // エラーの場合
  try {
    await store.get('error');
  } catch (e: any) {
    console.error(e.message);
  }
  // 直列でも毎回呼び出される(エラー処理してないため)
  try {
    await store.get('error');
  } catch (e: any) {
    console.error(e.message);
  }

  // 並列の場合はエラーになるのを待たずに API が実行される
  console.log(await Promise.allSettled([
    store.get('error'),
    store.get('error'),
    store.get('error'),
    store.get('error'),
  ]));
})();


既知の問題点:

  • fn を呼び出す際に this が CachedAsyncStore のインスタンスになってしまう(本来はコンストラクタなりで thisArg を受け渡すべきなのだが、面倒なのでやっていない)
  • key 以外の引数をコールバックに渡せない( Key をオブジェクトにすると中身は比較されずインスタンス毎に別の値としてキャッシュされてしまう)
  • 1つの Promise の値に対して何回も await することになるが、規格上それで問題ないのかを調べていない(筆者の母語C++ なので未定義動作が怖い)

他に問題点が ありましたら指摘をお願いします。

GCPのログエクスプローラで単語単位でエラーメッセージを検索したい場合は正規表現で \b を使えばいい

タイトル通り。
仕事でGCPを使う機会があり、ログを調べる際にログエクスプローラで単語単位の検索をしようと思ったが、その方法を検索しても一発では出てこなかったので、調べた結果を記す。

結論から言ってしまうと、

textPayload =~ "\bword\b"

で問題ない。 \b は単語境界にマッチする。

M:tG職工スタンダード第1回世界大会優勝レポ

無謀なる衝動
つよい

機械兵団の進軍の発売を目前に控えた今日この頃、皆様はどうお過ごしだろうか。
今回は 3/26(日) に池袋で行われた職工スタンダードの大会で幸運にも優勝することができたため、デッキ選択や当日のマッチアップ、握ったデッキについて解説したいと思う。
拙い文章ではあるが、最後までお付き合い頂ければ幸い。


職工スタンダードの特徴とデッキ選択

職工スタンダードとは、現行スタンダードのカードプールの中で、希少度がコモンまたはアンコモンに設定されているカードのみを使用可能なフォーマットである。
レアや神話レアが使えないスタンダード、と言い換えても良い。

鏡割りの寓話黙示録、シェオルドレッド偉大なる統一者、アトラクサ
こいつらは使えない

レアや神話レアが使えないという特性上、デッキを安く組めることが最大の魅力で、紙の M:tG を始めたいけど予算が…という方にはうってつけのフォーマットと言える。

さて、職工スタンダードは、レアおよび神話レアが使えないという都合上、

  • アンタップインできる多色土地
  • 1枚でゲームを決めるような強力なカード
  • 問答無用で戦場をリセットできる全体除去

の殆どが使えない。
これはつまり、

  • デッキを多色化する際に多色土地を使うとタップインが響き、かといって多色土地を使わないと色事故の危険性がつきまとうため、特に速いデッキにおいてデッキの多色化は抑制される
  • 対処できなければ勝てるような強力なカードを詰め込んだグッドスタッフ戦術は採りにくい
  • 盤面で劣勢になったときに仕切り直しがしにくい

ということを意味する。
一方で、

  • 指針アンコモンと呼ばれる、2色の組み合わせのデッキを作る際にシナジーの基本となるカードが使える
  • 除去やハンデス、打ち消し、火力といった妨害は大半が使える

ことから、単色または二色のシナジー寄りのデッキを組みつつ、相手のデッキのシナジーは汎用妨害カードで崩す、というのが一つの選択肢になる。
それを踏まえた上で、筆者が組んだデッキは以下の3つである。

赤単

www.hareruyamtg.com

アグロデッキの定番。 低マナ域の優秀なクリーチャーを展開し、火力で道をこじ開けつつライフを削り、最後の数点は火力を本体に撃ち込むことで削り切る。

ラクドスサクリファイス

www.hareruyamtg.com

シナジーデッキの代表格。 神河:輝ける世界を始めとした多くのエキスパンションで黒と赤の組み合わせに与えられている生け贄シナジーを活かし盤面を構築する。

青黒増殖コントロール

www.hareruyamtg.com

増殖をコンセプトとしたコントロールデッキ。 ほぼノンクリーチャーであり、唯一採用されいている《虚空翼の混種》も除去に強いため、相手の除去を腐らせられる。


この中から筆者は赤単を選択した。 その理由は、

  • 単色でありマナ関連の事故を起こしにくいこと
  • コンセプトはしっかりしているが個々のカードのシナジーはそこまで強くなく、安定してパフォーマンスを発揮できること
  • 勝負がつくのが早く、ラウンド間で休憩を取りやすいこと
  • 自分の動きを押し付けるデッキであるため、メタゲームを読んでサイドボードを最適化する必要がないこと
  • 単純に筆者がアグロデッキを握り慣れていること

といった要因が挙げられる。


赤単バーンの構築と調整

さて、赤単を握ることを決めた筆者が真っ先に採用を決定したカードがこちらになる。

mtg-jp.com

そう、アドバンテージ源だ。

というのも、職工スタンダードでは《無謀な嵐探し》や《轟く雷獣》といった強力なフィニッシャーが採用できないため、相手のライフを削り切る前に息切れしてしまう可能性が高い。
そこでこのカードがあれば、

  • 得られたカード・アドバンテージを活かし、相手の生物を除去した上でクロックを残せる
  • 盤面が膠着した場合に本体に投げつける火力を水増しすることができる

のである。
このカードは赤単色になった《表現の反復》とでも言うべきカードであり、

表現の反復
堂々のレガシー禁止カード

流石に3枚見て2枚アクセスできる《表現の反復》の方が強いが、2マナでアドバンテージを取れるこのカードは職工スタンダードにおいて極めて優秀だと考えている。

さて、このカードを活かすため、デッキの大まかな方針は固まった。

  • 土地は多めに23枚。 マナフラッドのリスクはあるが、土地を並べた方が《無謀なる衝動》は強く使えるため
  • クリーチャーと汎用火力*1を中心にデッキを組む
  • 採用するクリーチャーは汎用火力と相性の良いものを採用する
  • 相手の生物のみに当たる除去や自分のクリーチャーを強化するようなカードは、コスト・パフォーマンスこそ良いが、腐る可能性があるため極力採用しない

要するにバーンデッキである。
汎用火力と相性のいいクリーチャーとして、以下の3+1種類を採用。

mtg-jp.com
mtg-jp.com
mtg-jp.com
mtg-jp.com

《熊野と渇苛斬の対峙》は実質クリーチャー枠でありながら「クリーチャーでない呪文を唱えたとき」の能力を誘発させられ、
《僧院の速槍》《ケッシグの炎吹き》は汎用火力を詰め込んだ このデッキでは安定したダメージソースになり、
《炎の媒介者》は火力と絡めれば容易に3/3へと成長し、変身後は多少悠長ながらアドバンテージを得ることも可能な、どれも非常に優秀なカードである。

また、火力とのシナジーは特にないが、単純にスタッツが良く、かつ相手のライフゲインを封殺できる《巨大焦がし大口》も採用した。
mtg-jp.com

汎用火力としては、定番の《火遊び》《稲妻の一撃》に加え、3枚の《電位の負荷》を採用。

mtg-jp.com
mtg-jp.com
mtg-jp.com

競合する他の3マナインスタント汎用火力ではなく《電位の負荷》を採用した理由は、《熊野と渇苛斬の対峙》で置いた+1/+1カウンターや変身後の《炎の媒介者》に乗る炎カウンターを増殖できる可能性がある*2ためであるが、この枠は正直あまり強くなく、サイドアウトされることが多かった。

また、職工スタンダードというフォーマットの特性上、盤面が膠着して睨み合いになるケースが発生しやすいと判断したため、最後のひと押しに有効な《危険な爆風》も2枚採用。

mtg-jp.com

これは状況を選ぶカードで《無謀なる衝動》との相性も良くないため、サイドアウトされることが多かった(特に後手のとき)。

最後に土地基盤として、《山》23枚だとマナフラッドが頻発したため、3枚を《自律焼炉》に変更。

mtg-jp.com

タップインが痛いケースも多かったが、《自律焼炉》のドローが勝利に貢献してくれたケースはもっと多かったので満足している(それでも4枚は多いので3枚がベストだと思われる)。


サイドボードのカードに関しては、大会前日に《炎恵みの稲妻》の4枚投入を決めたが、これがドンピシャ。

mtg-jp.com

軽量で墓地対策も兼ねた除去は非常に強く、ほぼ全てのマッチアップでサイドインすることになった。

他に目を引くのは《通電の幻想家》だが、これは元々メインに入っていた名残である。

mtg-jp.com

2マナで戦場に出て1枚衝動ドローした後に4/3が残るのは強いと思って採用していたが、実際には4/3で殴り始めるまでに1ターンのラグがあるのが かなり辛く、メインから抜けていった。
サイドインされることもなかったため、おそらく次の大会ではサイドからも抜けてしまうと思われる。


当日のマッチアップ

そんなこんなで持ち込んだ赤単バーン。 大会の参加者は12名、スイスラウンド4回戦のちトップ4によるシングルエリミネーションで争われることになった。

第1ラウンド

対 赤単《上機嫌の解体》、先手、LWL

いきなりのミラーマッチ…と思いきや、デッキのチューンはほぼ別物といっていいものだった。
先手を取ることには成功したものの、第1ゲームをマリガンした後ダメージレースに負けて落とし、第2ゲームは先手でブン回って勝ち、第3ゲームは後手でワンマリ、お相手の攻勢を受けきれずに敗北。
対戦後に軽く話したところ、お相手のデッキはほぼ全てが1マナのカードでできており、土地も17枚に切り詰めていたらしい。
いきなり後がなくなってしまったが、切り替えて次のマッチに。

第2ラウンド

対 赤単アーティファクトアグロ、後手、WW

また赤単とのマッチ、とはいえお相手のデッキはかなりアーティファクトに寄せたもので、自分のデッキとも第1ラウンドの赤単とも別物だった。
《継ぎ接ぎ自動機械》を出されて対処に困ったものの、お相手が第1ゲームと第2ゲームの両方でマリガンしたのもあり、後続のアーティファクトを展開されず、何とか勝利。

第3ラウンド

対 青黒増殖、先手、WW

コントロールではない、毒で押し切るタイプの青黒増殖とマッチアップ。
火力でお相手のクリーチャーを捌いて勝利。

第4ラウンド

対 赤単果敢アグロ、後手、LWW

また赤単か。 とはいえデッキコンセプトは僕のものと異なり、パンプ呪文を多めに採用した型。
第1ラウンドはマリガンした後ダメージレースになり、差し切れそうなところまで行ったものの、こちらの除去にパンプを合わせられて負け。
第2ラウンドは先攻ブン回りで勝利。
第3ラウンドは残りライフ1まで追い詰められるものの、なんとかギリギリでお相手の生物を捌き切ることに成功。 何を引かれても負けの状況で決死の総攻撃を繰り返し、奇跡的に何も引かれず勝利。


3-1で4位抜け、決勝ラウンド進出。

準決勝

対 青単、後手、WW

デッキリスト

スイスラウンドの順位が低かったので後手。 とはいえ流石に相性が良く、かつ青単は環境にいることが容易に予想できたため《松明吹き》という専用サイドを用意していたのもあって勝利。

早めに決着したのでもう片方の対戦を観戦。 緑白エンチャントとジャンドのマッチアップ、緑白エンチャントとは当たりたくなかったので内心でジャンドを応援してたらジャンドが勝ってくれて一安心。

決勝

対 ジャンドリアニメイト、後手、LWW

デッキリスト

またしても後手。 サブマリン*3はこれが辛い。

第1ゲームはお互い事故(こちらマナスクリュー、お相手赤マナ出ず)でスタート。 お互い事故から脱した後は、お相手が《税血の収穫者》リアニを繰り返し、こちらの生物の生存権がなくなって負け。
第2ゲームは先手ブン回りで勝ち。
第3ゲームはお相手が若干もたついている間にライフを削り、最後はお相手のライフ残り7の状態から《熊野と渇苛斬の対峙》《火遊び》《火遊び》とプレイ、《ケッシグの炎吹き》の誘発と絡めて8点ダメージを与えて勝利。


勝因と今後の課題

勝因としては、運が良かった、というのもあるが、会場で最多勢力だった赤単を相手にリソース勝負で勝てる構成にしていたのが大きかったと思う。
サイドに《炎恵みの稲妻》を4枚採用していたのが勝因としては大きく、これがなければテンポ差で押し切られていたマッチアップも多かった(あとジャンドリアニメイトが税血を釣るのを妨害できたのも大きかった)。
相性の悪い緑白エンチャントと当たらなかったのも運が良かったが、仮に当たっていたとしても、除去多めの構成だったため、戦えていた筈である。

課題としては、《通電の幻想家》が残っているなどサイドが練りきれていない点で、これは全体火力などに換えるべきだった。
また、これは課題というかは微妙だが、《危険な爆風》や《電位の負荷》をサイドアウトする機会が多く、メインでの採用に疑問が残った。
とはいえマッチアップが偏っていたのも否めないため、もっと色々なデッキと当たってみたいものである。


最後に

まだまだ書きたいことはあるが、少々長くなってしまったため、この辺りで一旦筆を置こうと思う。
この記事を読んでくれた方が職工スタンダードに興味をもってくれたなら幸いである。
もし、この記事を切っ掛けに職工スタンダードを始めてみようと思ったなら、その時は是非 赤単バーンを試してみて欲しい。
あと、冒頭で紹介して結局大会には持ち込まなかったデッキも、興味を持ったなら回して調整してもらえると幸いである。
主催者の方によると、職工スタンダードの大会は今後も新弾が出るたびに行っていくとのことなので、我こそはと思う方は是非参加してみると良いのではないか。

*1:プレイヤーにも飛ぶ火力

*2:ただし炎カウンターに関しては注意が必要で、最初の炎カウンターはダメージを与えたことで乗るため《電位の負荷》単体では増殖できない

*3:最初に負けた後に全勝して決勝SEに残ること