共同運営・大会履歴の使い方

開発者・内部の処理を深く知りたい方向け

Apex Custom Manager
コードで見る仕組み

「どう使う?」の、その先へ。画面の裏側で行われる判断や計算を、実際のコードと具体例から読み解きます。

35のテーマ29の実コード抜粋機能別の索引

このページは、内部のコードに興味がある方のための追加資料です。通常の利用にコードの知識は必要ありません。操作方法を探している方は、一般ユーザー向けの基本ガイドはこちら。

対象:2.0.5 / コード確認日:
掲載時点の内部処理を説明しています。バージョン更新でコードや行番号が変わる場合があります。

このページの目次を開く

機能別の索引へ

1 このページの読み方

ソースコードを全部読まなくても、ひとつの関数から仕組みを知ることができます。まずは「何をしたい処理か」を読み、気になる行だけ追ってみてください。

実コードの抜粋

アプリ内の処理を、そのまま短く切り出したものです。出典のファイル名と行番号を付けています。型や前後の処理を省いているため、通常は単独では動きません。

説明用サンプル

動きを試しやすくするために用意した学習用の例です。実アプリ全体を再現するものではありません。実コードとの違いを明記します。

このページのコードは閲覧用ですページを開いてもコードは実行されず、Apexへの接続、試合操作、データの送信は行いません。コード全体や個別の設定ファイルではなく、仕組みを説明するために選んだ部分だけを掲載しています。

if は条件による分岐、for は繰り返し、return は結果を返して処理を終える行です。Goの nil は「値がない」ことを表します。

操作の準備は基本ガイド、設定の意味や使い分けはもっと深く知るガイドをご覧ください。

イベントが届いた後は、役割ごとに分かれる

ApexのLiveAPI → 受信データをイベントへ変換

変換したイベントを、用途に応じて処理

最新の状態を持つ

Store.Update

種類ごとの最新値と更新番号をメモリに保持し、更新を待つ処理へ通知します。

一時的な状態の更新へ →
試合の記録を作る

Session.Handle

開始・終了・試合中の出来事を振り分け、試合の状態や保存処理につなぎます。

イベントの振り分けへ →

読みやすく簡略化した流れです。実際にはログ記録や機能ごとの処理もあります。「メモリの最新値」と「保存された試合履歴」は同じものではありません。OBSなどの表示は、用途に合うデータをアプリから取得します。

気になる問いから読む

機能から探す

準備・試合運営・集計・記録・配信・AI・保守をひとつの索引にまとめました。「解説」は設定の意味、「仕組み・コード」は内部の判断や処理へ進みます。通常の画面操作に加え、接続設定が必要な機能と、現在の動作上の制限も扱います。

起動・接続・画面

試合を運営する

大会・ポイント・判定

予約・カスタム・音声

配信・外部送信

AIを使う

掲載対象は確認した2.0.4の機能です。内部コードは理解に役立つ短い部分を選び、接続情報や個別の試合データは掲載しません。連携先サービスの管理機能すべてや、ゲーム側の仕様全体を説明するものではありません。

2.0.2の期限表示:通常画面は、アプリが画面へ渡したローカル保護トークンを付けてヘルス情報を取得します。認証済みのローカル画面には有効期限を含む詳細状態を返し、操作トークンのないヘルス確認には期限や端末情報を含めません。期限が空のときだけ無期限として扱い、2040年以降を含む日付は通常の日付として保持・表示します。関連箇所は internal/httpapi/security.go 40–64行、internal/httpapi/server.go 165–203行です。

2 100ポイントを3人で分けたら?

端数があっても、全員分を足すと元のチーム点になるように分配します。

実コードの抜粋Go · 21行

出典:internal/points/logic.go 608–628行

func splitPlacementPoints(points int64, memberCount int) []int64 {
	if memberCount <= 0 {
		return nil
	}
	count := int64(memberCount)
	base, remainder := points/count, points%count
	// Go truncates division toward zero. Convert it to floor division so a
	// negative team value keeps the exact configured sum, matching Python.
	if remainder < 0 {
		base--
		remainder += count
	}
	result := make([]int64, memberCount)
	for index := range result {
		result[index] = base
		if int64(index) < remainder {
			result[index]++
		}
	}
	return result
}

ここを読むと、動きがわかる

  1. points/count で基本点、points%count で余りを求めます。
  2. 余りが負なら補正します。負の設定点でも合計が変わらないようにするためです。
  3. 全員に基本点を配り、余りの数だけ先頭から1ポイントずつ追加します。
チーム点人数分配結果合計
1003[34 33 33]100
−53[-1 -2 -2]−5
03[0 0 0]0
誰が余りを受け取る?実アプリでは、この関数を呼ぶ前にメンバーをNucleus Hash順に並べます。キル数順やランダムではありません。順位ポイントを有効にした場合の処理で、人数が0以下ならこの関数は nil を返します。
自分のPCで試せる、全体のサンプルを開く

上の関数を変更せず、結果を表示する main と標準ライブラリの読み込みを付けた説明用サンプルです。アプリ固有のパッケージは使いません。Apexやアプリの起動、通信、試合データの読み書きも不要です。

説明用サンプル実関数 + 学習用の実行部分
package main

import "fmt"

func splitPlacementPoints(points int64, memberCount int) []int64 {
	if memberCount <= 0 {
		return nil
	}
	count := int64(memberCount)
	base, remainder := points/count, points%count
	// Go truncates division toward zero. Convert it to floor division so a
	// negative team value keeps the exact configured sum, matching Python.
	if remainder < 0 {
		base--
		remainder += count
	}
	result := make([]int64, memberCount)
	for index := range result {
		result[index] = base
		if int64(index) < remainder {
			result[index]++
		}
	}
	return result
}

func main() {
	examples := []struct {
		points  int64
		members int
	}{
		{100, 3},
		{-5, 3},
		{0, 3},
	}
	for _, example := range examples {
		shares := splitPlacementPoints(example.points, example.members)
		var total int64
		for _, share := range shares {
			total += share
		}
		fmt.Printf("%d / %d -> %v (sum=%d)\n",
			example.points, example.members, shares, total)
	}
}

Goが使えるPCで、新しい作業フォルダの main.go に上のコード全体を保存し、そのフォルダで次を実行します。アプリのフォルダに置く必要はありません。

go run main.go

出力例:

100 / 3 -> [34 33 33] (sum=100)
-5 / 3 -> [-1 -2 -2] (sum=-5)
0 / 3 -> [0 0 0] (sum=0)

試すなら:{100, 3} を {101, 3} や {100, 4} に変え、分配先の合計と元の点が一致するかを見てみましょう。例の数値は説明用で、実際の大会設定や参加者のデータではありません。

画面でのポイント設定と計算ルールを見る →

3 試合の出来事を、どう振り分ける?

同じ入口に届くイベントでも、開始・終了と、試合中の更新では処理を分けています。

実コードの抜粋Go · 18行

出典:internal/matchstate/session.go 122–139行

func (s *Session) Handle(ctx context.Context, event liveapi.Event) error {
	if event.Type == "rtech.liveapi.MatchSetup" {
		return s.onSetup(ctx, event.Data)
	}
	if event.Type == "rtech.liveapi.MatchStateEnd" {
		return s.onEnd(ctx, event.Data)
	}

	s.mu.Lock()
	defer s.mu.Unlock()
	if s.current == nil || (s.state != Active && s.state != Ending) {
		return nil
	}
	timestamp, _ := intValue(event.Data["timestamp"])
	s.extractPositions(s.current, event.Data, timestamp)
	s.applyEvent(s.current, event.Type, event.Data, timestamp)
	return s.checkpointLocked(false)
}
  1. MatchSetup なら開始処理へ、MatchStateEnd なら終了処理へ渡します。
  2. それ以外はロックを取り、対象の試合があるか、状態が Active または Ending かを確認します。
  3. 位置と出来事を現在の試合に反映し、checkpointLocked(false) へ進みます。
この入口だけで「結果確定」になるわけではありません開始・終了の内部処理や、途中状態の保存を判断する処理は別の関数にあります。ここは振り分けの例です。終了イベントを受けただけで、すべての集計と保存が完了したと読むのは早計です。

前提:Session、liveapi.Event、状態の定義、補助関数などが必要です。ロックは更新が途中で入り交じるのを防ぐためのものです。この抜粋だけでは実行できません。

履歴の確認と、記録が欠ける場合の説明へ →

4 「最新の状態」をどう持っている?

受信するたびに種類ごとの最新値を置き換え、更新を待っている処理へ知らせます。

実コードの抜粋Go · 18行

出典:internal/store/store.go 27–44行

func (s *Store) Update(resultType string, value any) uint64 {
	s.mu.Lock()
	defer s.mu.Unlock()
	s.counter++
	now := time.Now()
	current := s.data[resultType]
	if current == nil {
		current = &entry{wait: make(chan struct{})}
		s.data[resultType] = current
	}
	close(current.wait)
	current.wait = make(chan struct{})
	current.value = value
	current.counter = s.counter
	current.updatedAt = now
	s.updatedAt = now
	return s.counter
}
  1. ロックを取り、全体の更新番号 s.counter を1つ増やします。
  2. その種類のデータが初めてなら、保存枠と通知用のチャネルを作ります。
  3. close(current.wait) で待機側に通知し、次回用のチャネルを用意します。
  4. 最新値、更新番号、更新時刻を入れて、今回の更新番号を返します。
項目ここで保持するもの読み違えやすい点
current.valueその種類の最新値過去の値をすべて追加し続けるリストではありません。
current.counterその値が更新された番号その種類だけの累計件数ではなく、全体の更新番号です。
current.wait次の更新を知らせるチャネルそのままOBSへ送る通信ではなく、アプリ内部の待機処理への通知です。
ここはメモリ上の一時的な保存です試合履歴をデータベースに保存する処理とは別です。アプリを閉じても残る記録の話と、動作中だけ持つ最新状態の話を分けると、全体を読みやすくなります。

前提:Store と entry の定義、初期化、更新を待つ側の処理は省略しています。単独実行用ではありません。

保存データとバックアップの説明へ →

5 どの大会に試合が記録される?

大会・選手別の武器統計は、取得時に確認済みの武器別名を正規化してから合算します。内部名と日本語名の重複行をまとめ、使用者数は正規化後の武器ごとにプレイヤー識別値のDISTINCTで計算します。集計済みの使用者数を足す方式ではありません。元の保存データは変更せず、未確認の別名は統合しません。

試合の開始処理では、画面で眺めている大会ではなく「アクティブな大会」を参照します。

実コードの抜粋Go · 22行

出典:internal/matchstate/session.go 196–217行

	err := s.db.WithTx(ctx, func(tx *sql.Tx) error {
		var tournamentID sql.NullInt64
		err := tx.QueryRowContext(ctx, "SELECT id FROM tournaments WHERE is_active=1 ORDER BY id LIMIT 1").Scan(&tournamentID)
		if err != nil && !errors.Is(err, sql.ErrNoRows) {
			return err
		}
		if tournamentID.Valid {
			snapshot.TournamentID = tournamentID.Int64
		}
		result, err := tx.ExecContext(ctx, `INSERT INTO matches
			(match_uuid, server_id, map, playlist_name, playlist_desc, datacenter,
			 aim_assist_on, anonymous_mode, start_timestamp, tournament_id)
			VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
			snapshot.MatchUUID, snapshot.ServerID, snapshot.Map, snapshot.PlaylistName,
			snapshot.PlaylistDesc, snapshot.Datacenter, snapshot.AimAssistOn,
			snapshot.AnonymousMode, snapshot.StartTimestamp, nullableTournamentID(tournamentID))
		if err != nil {
			return err
		}
		snapshot.MatchID, err = result.LastInsertId()
		return err
	})
  1. is_active=1 の大会を探し、見つかれば snapshot.TournamentID に入れます。
  2. その大会IDを含めて、matches に新しい試合の記録を追加します。
  3. WithTx の中をまとめて処理します。途中でエラーを返せば取り消し、成功すれば確定する仕組みです。
大会がないと、エラーになる?

大会が見つからない sql.ErrNoRows は、ここでは許容しています。その場合は大会未所属の試合として保存します。一方、データベースの別のエラーはそのまま返します。

SQLの「?」には何が入る?

VALUES (?, ...) の位置には、後ろに渡した試合情報が順番に入ります。掲載しているのは変数名で、実際の試合ID、参加者情報や接続先の値ではありません。

過去の試合が自動で移る処理ではありませんここで扱っているのは、新しい試合を作る時点の所属先です。その後にアクティブ大会を切り替えることと、保存済みの試合を別の大会へ追加・移動することは、別の操作です。

出典は開始処理 onSetup の途中です。データベース、テーブル、snapshot、nullableTournamentID などの周辺処理が必要で、単独では実行できません。

アクティブ大会と、保存済み試合の扱いを見る →

6 倒した賞金首が復活したら?

最後のリスポーンより後に成立する、最後の有効な撃破を探します。

実コードの抜粋Go · 20行

出典:internal/httpapi/tournaments.go 731–750行

	lastRespawnTime, lastRespawnID := int64(-1), int64(-1)
	if len(respawns) > 0 {
		last := respawns[len(respawns)-1]
		lastRespawnTime, _ = mapInt64(last, "timestamp")
		lastRespawnID, _ = mapInt64(last, "id")
	}
	var valid map[string]any
	for _, kill := range kills {
		killer := textOrEmpty(kill["killer_hash"])
		if killer == "" || killer == target {
			continue
		}
		killTime, _ := mapInt64(kill, "timestamp")
		killID, _ := mapInt64(kill, "id")
		if killTime < lastRespawnTime || (killTime == lastRespawnTime && killID <= lastRespawnID) {
			continue
		}
		valid = kill
	}
	return valid, nil
  1. 最後のリスポーンの時刻と記録IDを取り出します。
  2. 撃破者が不明な記録や、自分自身を撃破した記録を除外します。
  3. 最後のリスポーン以前の撃破を除外します。同じ時刻なら記録IDで前後を比較します。
  4. 残った撃破を順に valid へ入れるので、最後の有効なものが残ります。なければ nil です。
説明用の架空例:選手A・Bと賞金首の記録
出来事の順序この判定の結果
Aが撃破 → 対象がリスポーン有効な撃破なし
Aが撃破 → 対象がリスポーン → Bが撃破Bの撃破
撃破とリスポーンの時刻が同じ時刻だけでなく記録IDで前後を区別
「先に並べてある」ことが大切です省略した前半では、対象者の撃破とリスポーンを timestamp, id の昇順で取得します。並び順があるからこそ、最後に代入した記録が最後の有効撃破になります。撃破者の決定も前半で行います。

大会側では、終了時刻と勝者の記録がある大会所属試合を対象にします。この抜粋は賞金首の表示マーカー用の判定で、賞金の支払いや追加ポイントを自動処理するコードではありません。

出典は validBountyKill の後半です。対象者 target、並べた履歴、型変換などの補助関数が必要です。この部分だけをコピーしても動きません。

賞金首の指定と、表示のルールを確認する →

候補の作り方・大会からの継承

大会の賞金首候補は、勝者が確定した所属試合の参加者と、現在のロビーで競技チームに割り当てられた参加者を合わせます。ロビー情報を取得できれば初戦前にも指定できます。未割当と観戦者は現在ロビーの候補から除きます。

大会所属の試合は大会の賞金首設定を引き継ぎ、無所属の単独試合は別に指定できます。機能をOFFにしても対象指定は残りますが、表示やAI分析への入力から外れます。撃破者は付与先の記録を優先し、なければ攻撃者から解決してから、上記の有効撃破を判定します。

設定・候補・判定:internal/httpapi/tournaments.go 428–751行
試合側の継承:internal/httpapi/matchdata_more.go 1238–1355行

7 予約時刻に少し遅れたら?

時刻だけでなく、同じ予約の同じ操作を処理済みかどうかも確認します。

実コードの抜粋Go · 13行

出典:internal/httpapi/background.go 213–225行

func (s *Server) scheduleDue(ctx context.Context, scheduleID int64, actionType, actionRef string, target, now time.Time) bool {
	delta := now.Sub(target)
	if delta < 0 || delta > scheduleGrace {
		return false
	}
	var marker int
	err := s.database.QueryRow(ctx, `SELECT 1 FROM schedule_action_log WHERE schedule_id=? AND action_type=? AND action_ref=? AND scheduled_for=?`, scheduleID, actionType, actionRef, target.Format("2006-01-02 15:04:05")).Scan(&marker)
	return errors.Is(err, sql.ErrNoRows)
}

func (s *Server) markScheduleComplete(ctx context.Context, scheduleID int64, actionType, actionRef string, target time.Time) {
	_, _ = s.database.Exec(ctx, `INSERT OR IGNORE INTO schedule_action_log(schedule_id,action_type,action_ref,scheduled_for) VALUES(?,?,?,?)`, scheduleID, actionType, actionRef, target.Format("2006-01-02 15:04:05"))
}
  1. now.Sub(target) で予定時刻からの経過時間を求めます。
  2. まだ予定時刻前、または許容時間を過ぎていれば対象外にします。
  3. 同じ予約・操作・予定時刻の履歴を照会し、「該当する行がない」ときだけ実行候補にします。
  4. 別の処理で操作を送信し、成功扱いになった後に markScheduleComplete で履歴を残します。

scheduleGrace は、同じファイルの16行目で 120 * time.Second と定義されています。

予定からの経過時間この関数の判断
予定時刻より前対象外
0秒〜120秒処理済み履歴がなければ候補
120秒を超える対象外
履歴の照会自体でエラー「未処理」と決めつけず、候補にしない
予約の実行や、ゲーム開始を保証するコードではありませんこれは候補の判定と履歴記録です。アプリやPCが止まっている間は処理されません。操作の送信成功とゲーム側の反映も同じではなく、厳密に一度だけの実行を保証する仕組みとして説明しているものではありません。

前提:Server、データベース、時刻やエラーを扱う型、実際に操作する呼び出し側などが必要です。単独では実行できません。公開ページから予約を登録・実行する機能はありません。

自動開始の設定と、遅れた予約の注意点へ →

実行される順序と、重複を防げる範囲

予定の処理は、設定の事前適用、該当するチャット、開始時の設定再適用、マッチメイキング開始へ進みます。ひとつの予定でも操作ごとに時刻と処理済み記録を確認するため、一部成功・一部失敗があり得ます。

時刻は実行PCのタイムゾーンで解釈します。同じ予定ID・操作・予定時刻の再処理は抑えますが、別々の予約として登録した同一内容までまとめて抑止する仕組みではありません。予約チャットと音声の時刻予約でも、実行記録や猶予は異なります。

実行側:internal/httpapi/background.go 156–245行

8 OBSの数字は、どう更新される?

表示用データをアプリから取得し、画面の種類に合った描画処理へ渡します。

実コードの抜粋JavaScript · 23行

出典:webassets/static/js/overlay_stats.js 86–108行

    async function loadData() {
        const endpoint = overlayType === 'latest' ? '/api/overlay/latest-data' : '/api/overlay/live-data';
        try {
            const response = await fetch(endpoint, { cache: 'no-store' });
            const data = await response.json();
            if (!data.success) {
                renderEmpty(data.error || 'データ取得エラー');
                scheduleNext(data.settings);
                return;
            }
            if (overlayType === 'latest') {
                renderLatest(data);
            } else if (overlayType === 'observer') {
                renderObserver(data);
            } else {
                renderLive(data);
            }
            scheduleNext(data.settings);
        } catch (error) {
            renderEmpty(`接続エラー: ${error.message}`);
            scheduleNext();
        }
    }
  1. 直近の試合を表示する場合と、それ以外で取得先を切り替えます。
  2. fetch で取得した内容をJSONとして読み、data.success を確認します。
  3. 直近の試合・観戦者視点・試合中の状況に合わせて描画関数を選びます。
  4. 成功時もエラー時も、scheduleNext で次の取得を予約します。
ここに書かれたAPIは、このサイトのURLではありません/api/overlay/... はアプリが提供する画面で使う相対パスです。このマニュアルサイトへ貼り付けても表示用データは取得できません。アプリ側の接続準備や周辺処理は省略しています。
何秒ごとに取り直す?

続く scheduleNext(同ファイル110–116行)では、設定された更新間隔を使い、最短2秒に調整しています。値が未指定などの場合の既定値は5秒です。ここで示しているのはこの取得処理の流れで、観戦者表示に関わる更新経路をすべて列挙したものではありません。

前提:overlayType、各描画関数、タイマー処理などが別に定義されています。これはOBSを操作したり映像を取得したりするコードではなく、ブラウザソース用の表示データを扱う部分です。単独実行用ではありません。

OBSへの登録と、表示項目の設定を見る →

コードと設定を、行き来して読む

条件式の意味がわかったら、実際に触る設定や利用上の注意も確認してみてください。使い方は基本ガイド、結果が変わる理由や設定の関係はもっと深く知るガイドにまとめています。

このページは選んだコードを読むための資料です。内部の関数名や相対パスを、外部連携向けに固定・保証された仕様として案内しているものではありません。

4種類の表示は、同じ記録を使うわけではない

OBS表示の取得元と更新方法
表示取得する記録更新
イベント通知受信したキル・リスポーン・全滅などWebSocketの通知
進行中の成績現在の試合の状態と集計設定間隔の取り直し
最新マッチ終了時刻のある最新の保存済み試合設定間隔の取り直し。勝者あり条件は使わない。
観戦者の情報指定観戦者の最新視点と対象選手定期取得と観戦先変更の通知

表示件数は1〜30、更新間隔は2〜60秒(既定5秒)です。最新マッチの武器・アイテム使用率は使用人数を観戦者以外の参加者数で割り、武器ダメージ率は全武器ダメージに占める割合です。使用回数の割合とは異なります。

観戦者表示はロビーで取得した対象のハッシュを選択して保存します。保存済みの対象も再利用できます。未指定・未保存なら設定を案内し、指定した対象の視点がない場合は待機します。別の観戦者の最新視点へはフォールバックしません。所持品は全アイテムの汎用一覧ではなく、回復、投げ物、バックパック、ノックダウンシールドなどを決まった配置で描きます。OBS側で観戦者を選ぶことと、自動カメラを有効にすることは別です。

テストと実際の通知を区別します統計用のテストURLは固定サンプルですが、イベント通知のテストは実際の配信経路にも流れます。テストにあるキル節目・キルリーダーなどの全演出が実試合で自動発火するわけではありません。通常URLは待機中・データなし・通信失敗で透明になる場合があり、空表示だけで故障とは断定できません。

表示データと通知:internal/httpapi/overlay.go 425–607行
観戦更新・所持品・テスト:webassets/static/js/overlay_stats.js 137–157行、279–386行、487–633行

2.0.3の操作画面は、目的別カテゴリをTAB_GROUPSに定義し、既存の画面IDへ対応付けます。lastTabByGroupでカテゴリごとの直前の画面を記憶し、利用できない機能へ移動しようとした場合はホームへ戻します。高度な設定だけをサイドバー下部へ分け、ホームの4ステップは画面案内に限定します。大会選択を起動時に強制するダイアログは開かず、記録先の状態を確認します。機能情報は直接のオブジェクトとfeaturesに包まれた形式の両方に対応し、使用できない連携は表示しません。

9 起動・画面・保存先の全体像

ひとつのアプリの中で、ローカル画面、Apexとの通信、試合の保存、任意の連携が役割を分担します。

起動時に設定と保存先を決め、データベースと各機能を初期化します。管理画面とLiveAPIの待ち受けは同じPCのループバックに限定され、ブラウザの画面がローカルのHTTP処理を呼び出します。公開しているこのガイドが、管理画面のサーバーを兼ねているわけではありません。

HTML・JavaScript・CSS・既定設定はアプリに埋め込まれています。標準のマップ画像は外部ファイルです。画面ファイルを配布用EXEの横で編集すれば、そのまま埋め込み画面が変わるという構成ではありません。

実行時のデータはインストール先と分けて保存します。通常は %APPDATA% 配下の ApexCustomManager に設定、試合DB、ログ、復旧用スナップショットを持ちます。メモリ上の最新値、DBの試合履歴、機能別設定ファイルは別の役割です。

画面は通常Edgeのアプリウィンドウで開き、利用できない場合は既定ブラウザへ切り替えます。起動時の認証確認を待たずに画面を準備しますが、有効なライセンスなしで全機能を使えるという意味ではありません。

通常のEdgeの独立ウィンドウを閉じると、アプリの終了処理へ進みます。既定ブラウザへ切り替わった場合はタブを閉じてもアプリが残ることがあるため、保存完了とアプリ本体の終了を別に確認します。通常の終了は独立ウィンドウの終了、OSの終了シグナル、更新処理からの停止要求で管理されます。

条件・注意点配布アプリの利用にGoの開発環境は不要です。管理用のローカル画面をそのままインターネットへ公開する構成は、このガイドの対象外です。

関連する処理:
起動と機能の組み立て:cmd/apexcustom/main.go 111–214行
実行時設定と保存先:internal/appconfig/config.go 29–110行
管理画面の起動:internal/desktop/open_windows.go 32–63行
埋め込みと外部画像:webassets/embed.go 5–12行

10 Apexとの接続・新着待ち・情報の鮮度

「つながっている」「新しいデータを受け取った」「ゲームで操作が完了した」は別の状態です。

LiveAPIから届くバイナリはスキーマに沿ってイベントへ変換され、最新値の保管と試合記録へ渡されます。初回の接続案内は現在のLiveAPIポートから起動オプションを作り、確認済みの状態を設定に残します。

参加者一覧や設定を読み込むときは、送信前の更新番号を覚え、それより新しい対象データが届くのを待ちます。参加者は5秒、設定や禁止状態は2秒の待機を使い、間に合わなければ空の結果になる場合があります。

送信はロックで直列化して同時書き込みを避けますが、保存して後から再送するキューではありません。送信処理の成功はWebSocketへの書き込み成功で、ゲーム内の反映完了や試合開始・終了を確認した結果ではありません。

接続数と受信データの新しさは別に評価します。ヘルス表示では30秒を基準に鮮度を見ており、つながっていても表示が古い場合は、受信・ゲーム側の状態も確認します。

条件・注意点空の参加者一覧や禁止一覧を「誰もいない」「禁止なし」と即断しないでください。通常の手順ではApexの画面と応答を確認し、必要なら再取得します。
実コードを見る:Apexとの接続・新着待ち・情報の鮮度
実コードの抜粋Go · 15行 · 閲覧用

出典:internal/httpapi/legacy.go 145–159行

送信後、指定種類の更新番号が進むまで待つ部分です。タイムアウトとデータなしを処理し、取得した値を返します。parent・timeout・before・resultTypeなどは前半で用意するため、この抜粋だけでは動きません。

	waitContext, cancelWait := context.WithTimeout(parent, timeout)
	updated := s.store.WaitForUpdate(waitContext, resultType, before)
	cancelWait()
	if !updated {
		return map[string]any{}, false
	}
	value, ok := s.store.Get(resultType)
	if !ok {
		return map[string]any{}, false
	}
	result, ok := value.(map[string]any)
	if !ok {
		return map[string]any{}, false
	}
	return result, false

関連する処理:
受信と送信の直列化:internal/liveapi/server.go 153–195行
新着の取得待ち:internal/httpapi/legacy.go 121–160行
接続と鮮度の判定:internal/httpapi/server.go 165–211行
初回ガイド状態:internal/httpapi/setup.go 21–42行

11 別ウィンドウ・定期更新・重複取得の抑止

表示対象と更新周期を窓ごとに持ち、同じ取得を重ねないようにしています。

プレイヤー・マッチ・ポイント・総合順位は、表示専用の別ウィンドウとして開けます。別モニターへの配置はできますが、ブラウザ側でポップアップが禁止されていると開けません。

更新の目安は、プレイヤー約2秒、マッチ約1秒、アクティブ大会のポイント約3秒、指定大会の総合順位約5秒です。取得が終わる前に次の周期が来た場合は、その回を見送ります。通信が遅いと表示間隔も伸びます。

ポイント窓は現在アクティブな大会、総合順位窓は開くときに指定した大会を対象にします。進行中1試合のリアルタイムポイント表示は別で、同じ数字を別デザインにしているだけではありません。

既に開いている総合順位窓はフォーカスを戻す経路があるため、対象大会を変えるときは窓を閉じて開き直します。表示対象を指定する時点と、自動更新する時点の違いが理由です。

実コードを見る:別ウィンドウ・定期更新・重複取得の抑止
実コードの抜粋JavaScript · 9行 · 閲覧用

出典:webassets/static/js/dashboard.js 141–149行

keyが実行中の集合にあれば次の取得を見送り、成功・失敗のどちらでもfinallyで集合から外します。refreshFlightsというSetを外側で定義します。待機キューを作ったり自動再送したりする関数ではありません。

async function runRefreshSingleFlight(key, refreshFunction) {
    if (refreshFlights.has(key)) return;
    refreshFlights.add(key);
    try {
        await refreshFunction();
    } finally {
        refreshFlights.delete(key);
    }
}

関連する処理:
同じ取得の重複防止:webassets/static/js/dashboard.js 141–149行
別ウィンドウを開く:webassets/static/js/dashboard.js 2157–2219行
ポイント更新と大会指定:webassets/static/js/dashboard.js 12526–12567行

12 ロビー作成・参加者取得・参加コード

作成要求、参加者の再取得、参加コードの共有はそれぞれ別の操作です。

管理画面の「ロビーを作成」は、ローカルのHTTP処理を通じてApexのロビー作成操作へ変換されます。画面は返った応答のエラーを確認し、成功扱いのときは約1秒後に参加者情報を取り直します。

参加者の取得では新しい応答を待ち、一覧表示、保持しているロビー情報、チーム割当の候補を更新します。作成操作の送信成功だけで、参加者の取得や参加コードの確定まで完了したとは限りません。

参加コードを取得した際、Discordのオートスタート用Botと送信先が設定されている場合には、Bot向けコマンドを自動投稿する経路もあります。「カスタムマッチコード」のプレビュー作成でも同じ取得処理を使うため、確認後の通常投稿とは別に、プレビュー取得時点でBot向け投稿が起きる場合があります。

条件・注意点参加コードの取得が外部投稿を伴う設定もあります。本番の送信先へテスト内容を流さないよう、Discordの用途別送信先を確認してください。

関連する処理:
ロビー作成と再取得:webassets/static/js/dashboard.js 2075–2149行
参加者表示の更新:webassets/static/js/dashboard.js 1334–1363行
参加者・参加コード取得と連携:internal/httpapi/legacy.go 121–178行
コードのプレビューと取得処理:webassets/static/js/dashboard.js 11612–11633行

13 チーム割当・個別キック・終了補助

見えているチーム番号と内部のチーム番号を変換し、対象者を識別して操作します。

ロビー情報の内部番号は未割当が0、観戦者が1、競技チームが2以降です。画面では競技チームから1を引いて表示し、割当では表示番号に対応する内部値を送ります。試合結果のモード別チーム統合とは別の変換です。

チーム割当や個別キックは、名前の表示だけでなくハードウェア名やNucleus Hashなどの識別情報を使って対象を指定します。候補を更新し、同名プレイヤーを取り違えないよう確認します。

終了補助で受け付ける方法は「マッチメイキング停止」「ロビー退出」「停止してから退出」の3種類です。停止と退出を順に送っても、進行中の試合が必ず終了するわけではありません。

終了補助のフォームは「高度な設定」→「診断・終了補助」の折りたたみにあります。画面は受け付ける3種類だけを選択肢にし、確認後にmethodを送ります。未対応の一括キック、送信間隔、対象範囲の入力は置いていません。

条件・注意点全員Kick系の選択肢を利用可能な終了方法として案内しません。個別キックは別の機能です。終了補助は緊急時の補助であり、通常の試合開始・終了の代わりには使いません。

関連する処理:
ロビーのチーム表示:webassets/static/js/dashboard.js 1374–1396行
対象を指定した割当・キック:internal/httpapi/legacy.go 329–366行
終了補助で受け付ける3方式:internal/httpapi/legacy.go 599–653行
終了補助の画面項目:webassets/templates/index.html 282–304行

14 モード・マップ・ルール・選択肢のカタログ

選択肢への登録、Apexへの適用、履歴の地図表示は別々の仕組みです。

画面で選んだモードとマップは、Apexが受け付けるPlaylist名へ変換して送ります。管理者チャット、チーム名変更、自己割当、エイムアシスト、匿名モードの状態も同時に組み立てます。

受け取り側では許可したフィールドと値の型を確認します。知らない設定名やbooleanが必要な項目への別の型は受け付けないため、画面で送る形式とサーバー側の検証を合わせて読むと理解できます。

モード・マップ追加は、表示名とPlaylist名をカタログへ保存する機能です。同じモード・マップの組があればPlaylist名を更新します。この保存だけでゲーム側へルールは適用されません。

正しいPlaylist名が必要で、名前を追加すれば存在しないゲームモードが動くわけではありません。また履歴で使う画像ファイルや位置合わせは、このカタログではなくマップ設定で扱います。

実コードを見る:モード・マップ・ルール・選択肢のカタログ
実コードの抜粋JavaScript · 20行 · 閲覧用

出典:webassets/static/js/dashboard.js 1140–1159行

Playlistの選択を確認し、チェックボックスから設定オブジェクトを作る部分です。settingsオブジェクト、画面要素、safeGetValue・safeGetElement・showNotificationなどの定義が必要です。この部分は設定を返すだけで、単独でApexへ適用するものではありません。

    const playlist = safeGetValue('mapSelect').trim();
    if (!playlist) {
        showNotification('モードとマップを選択してください', 'error');
        return null;
    }
    settings.playlistName = playlist;

    const adminChatEl = safeGetElement('adminChat');
    const teamRenameEl = safeGetElement('teamRename');
    const selfAssignEl = safeGetElement('selfAssign');
    const aimAssistEl = safeGetElement('aimAssist');
    const anonModeEl = safeGetElement('anonMode');

    if (adminChatEl) settings.adminChat = adminChatEl.checked;
    if (teamRenameEl) settings.teamRename = teamRenameEl.checked;
    if (selfAssignEl) settings.selfAssign = selfAssignEl.checked;
    if (aimAssistEl) settings.aimAssist = aimAssistEl.checked;
    if (anonModeEl) settings.anonMode = anonModeEl.checked;

    return settings;

関連する処理:
送信する設定値の組み立て:webassets/static/js/dashboard.js 1137–1160行
許可項目と型の検証:internal/httpapi/legacy.go 368–403行
モード・マップカタログの保存:internal/httpapi/legacy.go 665–738行

15 レジェンド禁止の取得・選択・適用

画面でのチェック変更と、ゲームへの禁止一覧の適用を分けています。

Apexから現在の禁止状態とレジェンドの識別名を取得し、画面の一覧を組み立てます。表示名と送信に使う識別名は同じものとは限りません。

チェック変更や一括選択は、まず画面の状態だけを変えます。「禁止を適用」の送信時に、チェックされた識別名を集めて禁止一覧として渡します。1人ずつ別々の禁止APIを順番に呼ぶ構成ではありません。

解除も一覧の送り直しで行います。チェックを外すだけではゲーム側へ反映されず、取得できずに空になった一覧を禁止なしと誤認しないことが大切です。

実コードを見る:レジェンド禁止の取得・選択・適用
実コードの抜粋JavaScript · 12行 · 閲覧用

出典:webassets/static/js/dashboard.js 372–383行

選ばれたチェックボックスのvalueを配列へ集めて送信する、フォーム処理の抜粋です。DOM要素やsubmitFormの定義が必要で、このガイド内では実行しません。

    // Legend ban form
    const setLegendBanForm = document.getElementById("setLegendBanForm");
    if (setLegendBanForm) {
        setLegendBanForm.addEventListener('submit', function(e) {
            e.preventDefault();
            const checkedLegends = Array.from(document.querySelectorAll('#legendSelectionGrid input[type="checkbox"]:checked'))
                .map(checkbox => checkbox.value);

            const formData = { legendRefs: checkedLegends };
            submitForm('/set_legend_ban', formData, e.submitter);
        });
    }

関連する処理:
選んだレジェンド一覧の送信:webassets/static/js/dashboard.js 372–383行
取得と表示、一括選択:webassets/static/js/dashboard.js 1467–1620行
状態取得と禁止一覧の適用:internal/httpapi/legacy.go 485–544行

16 即時チャット・予約チャット・事前設定

即時送信と、試合の予定に紐づく送信では入力と実行のタイミングが異なります。

即時チャットは入力した文章を現在のApexロビーへ送ります。Discordへメッセージを投稿する処理ではありません。

予約チャットは試合の開始予定に紐づき、「開始の何分前」または「予定日の指定時刻」から送信時刻を決めます。複数行は空行を除いて別々のメッセージとして保存します。

予約処理では設定の事前適用、チャット送信、開始時の設定再適用、マッチメイキング開始を扱います。チャットだけが成功し、その後の開始に失敗することもあるため、予定全体をひとつの成功と決めつけません。

判定は実行PCの時刻を使い、予定時刻から120秒以内の未処理項目が対象です。アプリ停止や未接続が続いて時間を過ぎた予約が、再開後に必ず実行されるわけではありません。

条件・注意点予約の履歴は送信処理が成功したときに残すもので、Apex上の表示や開始の完了保証ではありません。日時・有効化・接続・ゲーム側の準備をあわせて確認してください。
実コードを見る:即時チャット・予約チャット・事前設定
実コードの抜粋Go · 18行 · 閲覧用

出典:internal/httpapi/background.go 181–198行

相対分指定から時刻を作り、指定時刻がある場合はそちらを使う部分です。対象時刻と実行履歴を確認し、送信成功後に記録します。予約データ、DB、LiveAPIと周辺関数が必要で、単独実行用ではありません。

	for _, chat := range chats {
		chatID := fmt.Sprint(chat["id"])
		var chatAt time.Time
		if absolute := strings.TrimSpace(textOrEmpty(chat["send_time"])); absolute != "" {
			chatAt, err = parseScheduleDateTime(textOrEmpty(row["scheduled_date"]), absolute, now.Location())
		} else {
			chatAt = target.Add(-time.Duration(int64Value(chat["minutes_before"])) * time.Minute)
		}
		if err != nil {
			continue
		}
		if s.scheduleDue(ctx, scheduleID, "chat", chatID, chatAt, now) && s.sendScheduledLiveAction(ctx, "customMatch_SendChat", map[string]any{"text": textOrEmpty(chat["message"])}) {
			s.markScheduleComplete(ctx, scheduleID, "chat", chatID, chatAt)
			if !s.waitForBackground(500 * time.Millisecond) {
				return context.Canceled
			}
		}
	}

関連する処理:
即時チャット:internal/httpapi/legacy.go 405–417行
複数行と時刻指定:webassets/static/js/dashboard.js 15514–15568行
予約の入力検証と保存:internal/httpapi/matchdata_more.go 1409–1535行
設定反映・チャット・開始の処理:internal/httpapi/background.go 156–245行

17 全ポイント要素・倍率・上限・丸め方

同じ数字でも、何を数え、どの段階で倍率や上限を適用したかによって意味が変わります。

個人の総合点は基本点、武器ボーナス、アイテム点、分配した順位点、各ペナルティの合計です。戦闘イベントが残っている種類はイベントから数え、古い記録などでイベントがない場合は一部の保存済み統計を使います。

リング倍率はキル・アシスト・ノックダウン・ダメージに適用します。全得点を最後に一律で掛ける倍率ではありません。リング情報のない旧記録のダメージへ、リング0の倍率を勝手に適用することもしません。

自分や味方へのダメージは通常ダメージの加点から除き、対象イベントの回数で減点します。キルの付与先が記録されていれば攻撃者より優先し、自部隊が全滅した時刻より後のキルは通常点や武器キルボーナスから除きます。同時刻は除外しません。

上限は統計のキル数・ダメージ自体を切り捨てるのではなく、対象点を比率で縮めます。個人キルとチームキルの上限は厳しい比率を一度だけ使い、武器ボーナスは別枠です。端数には偶数丸めとチームの予算補正があり、単純に人数分を丸めて足す計算とは異なります。

ダメージ点はリング別に重み付けしたダメージを合算し、必要量で割って倍率を掛けてから、最後に小数を0に近い整数へ切り捨てます。最後の最低総合点の補正により、総合点が内訳合計と一致しない場合があります。全体OFFは点を0にする設定で、試合統計を削除するものではありません。

初期設定と、計算に使う記録
要素初期値・状態対象と注意
キル / アシスト / ノック100 / 50 / 25有効な対象イベント。リング倍率を適用できる。
ダメージ100ダメージで1点、倍率1対象ダメージを合算後に計算。必要量0ではダメージ点が無効。
蘇生 / リスポーン30 / 20味方を蘇生した・リスポーンさせた回数。リング倍率は対象外。
武器ボーナス初期OFF武器別にキルとノックを分ける。単一数値の設定はキルのみ。
アイテム初期OFF対象分類のuseの数量。拾う・捨てる・投擲イベント自体とは区別。
順位初期OFFチームに一度配点し、選手へ整数分配。
死亡 / 被ノック−50 / −10該当するイベントの判定に基づく。自殺は別項目。
早期退出−100試合終了前・自部隊全滅前などの退出。全滅後や勝敗決着後の例外あり。
自傷 / 味方ダメージ−5 / −20ダメージ量ではなく、対象イベントの回数で計算。
自殺−75付与先、次に攻撃者から解決した撃破者が空、または被害者自身の場合を判定。
チーム / 個人キル上限5 / 5、初期OFF通常キル点へ厳しい方の比率を1回だけ適用。
アシスト / ダメージ上限10 / 3000、初期OFF各得点へ独立して適用。上限0を有効にすると対象点は0。
総合点の下限初期未設定加減点の合算後に個人総合点を補正。
条件・注意点表は初期値であり、現在の大会設定ではありません。上限やボーナスは値を入力しただけでなく有効化も確認してください。受信しなかった出来事を補って計算できるわけではありません。
実コードを見る:全ポイント要素・倍率・上限・丸め方
実コードの抜粋Go · 20行 · 閲覧用

出典:internal/points/logic.go 296–315行

重み付きダメージをまとめて点へ変換します。250÷100×2なら5点、10+20+70を合算して100で割れば1点です。引数の型、math・math/big、倍率の補助関数などが必要で、設定入力の検証は別の処理です。

func calculateDamagePoints(stages map[int64]int64, basic BasicSettings, rings RingSettings) int64 {
	if basic.DamagePerPoint <= 0 || math.IsNaN(basic.DamagePerPoint) || math.IsInf(basic.DamagePerPoint, 0) ||
		math.IsNaN(basic.DamageMultiplier) || math.IsInf(basic.DamageMultiplier, 0) {
		return 0
	}
	// Settings are decimal values. Exact decimal arithmetic keeps an integer
	// boundary (such as 10+20+70 damage / 100) independent of map iteration order.
	total := new(big.Rat)
	for stage, damage := range stages {
		weighted := new(big.Rat).Mul(new(big.Rat).SetInt64(damage), decimalRatio(ringMultiplier(rings, stage)))
		total.Add(total, weighted)
	}
	total.Quo(total, decimalRatio(basic.DamagePerPoint))
	total.Mul(total, decimalRatio(basic.DamageMultiplier))
	integer := new(big.Int).Quo(total.Num(), total.Denom())
	if !integer.IsInt64() {
		return 0
	}
	return integer.Int64()
}

関連する処理:
初期設定:internal/points/settings.go 79–94行
加減点と上限・倍率:internal/points/logic.go 118–480行
キル付与先と名前の正規化:internal/points/logic.go 496–554行

18 大会管理・順位・同点・再計算・テンプレート

表示中の大会、試合の所属先、計算に使う設定、保存済みの点を切り分けます。

大会は作成・編集・アクティブ化・解除・完了・削除を扱います。試合を自動で受け取るアクティブ大会は同時に1つで、表示中の大会を切り替える操作とは別です。大会の削除は試合そのものの削除ではなく、所属を解除します。

大会順位は、終了時刻と勝者の記録がある所属試合を、現在の大会ルールで都度計算します。履歴に保存された点や、選択試合の保存済みポイントを合算する表示とは、設定変更後に違う結果になる場合があります。

個人成績はNucleus Hashで通算します。表示名や所属は最新試合に合わせますが、チーム点は各試合で実際に所属していたチームへ残します。途中でチームを移った選手の点がすべて新チームへ移るわけではありません。

大会の同点は、個人では内部識別値の文字列順、チームでは内部チーム番号順に並べ、1からの連番を付けます。最多キルなどの競技規定に基づく決着や同順位表示ではありません。この規則をすべての履歴・集計画面へ一般化しないでください。

履歴の点も更新するときは再計算で保存済みの点を置き換えます。このとき全滅後キルに由来する選手・武器・リングの残存キル集計も補正しますが、raw戦闘イベント自体を削除する処理ではありません。

ポイントテンプレートには作成・一覧・編集・削除があり、作成元は共通の保存済みポイント設定です。大会専用設定を編集中なら、その内容が自動でテンプレートへ入ると考えず、保存元と適用先を確認します。

点数の参照元の違い
表示・機能使う点数設定変更後の注意
大会順位・公開結果現在の大会設定で対象試合を計算勝者ありの終了済み大会所属試合が対象。
履歴のポイント保存済みの計算結果必要な試合で再計算を実行。
ポイントの複数試集合計保存済みポイントの合算大会順位の再計算と同じ操作ではない。未指定は最新10試合、明示指定は最大100試合。
条件・注意点進行中の試合の所属変更・削除は保護されます。別大会の試合を追加する場合は元の所属を先に解除します。大会の同点時の扱いが大会規定に合うか、主催者が確認してください。
実コードを見る:大会管理・順位・同点・再計算・テンプレート
実コードの抜粋Go · 11行 · 閲覧用

出典:internal/httpapi/tournaments.go 856–866行

大会の個人順位を総合点の降順で並べ、同点なら内部識別値を比較して順位番号を付ける部分です。事前の集計結果と変換用の補助関数が必要です。表示名順やキル数順ではありません。

	sort.SliceStable(list, func(i, j int) bool {
		a, _ := mapInt64(list[i], "total_points")
		b, _ := mapInt64(list[j], "total_points")
		if a != b {
			return a > b
		}
		return fmt.Sprint(list[i]["nucleus_hash"]) < fmt.Sprint(list[j]["nucleus_hash"])
	})
	for i, player := range list {
		player["rank"] = i + 1
	}

関連する処理:
大会状態と試合所属:internal/httpapi/tournaments.go 173–408行
順位・選手/チーム/武器/アイテム集計:internal/httpapi/tournaments.go 753–1041行
テンプレート:internal/httpapi/tournaments.go 1214–1327行
保存点の集計・履歴・再計算:internal/httpapi/points.go 469–653行
保存点の置換とキル集計補正:internal/points/database.go 53–179行

19 アイテム分類・武器名・使用ポイントの候補

ゲーム内で使用を禁止する設定ではなく、記録をどの得点分類として扱うかの設定です。

アイテムや武器の名前から末尾のLevel表記と空白を整理し、設定と記録を照合します。正規化した結果が重複するポイント設定名は、曖昧なまま保存しないよう検証します。

分類は利用者が指定したものを優先し、指定がなければ既定の名前パターンで決めます。候補一覧は所持品・武器ログ・ポイント設定・分類設定から集めます。

初期状態でアイテム点の候補になるのは回復・投げ物・サバイバル・その他です。武器・弾薬・アタッチメント・装備・アビリティ・環境ダメージは候補外ですが、分類の手動指定で扱いを変更できます。

加点には対象のuse数量を使います。投げ物を候補にしたからといって投擲イベントを自動で使用回数へ置き換えたり、拾っただけで使用点を付けたりするわけではありません。

実コードを見る:アイテム分類・武器名・使用ポイントの候補
実コードの抜粋Go · 10行 · 閲覧用

出典:internal/points/logic.go 245–254行

アイテム点が有効なら使用データを走査し、分類が候補かを確認して数量×配点を加えます。選手別の使用データと設定、itemCandidateなどが必要な部分抜粋です。

	if settings.ItemPoints.Enabled {
		lookup := normalizedLookup(settings.ItemPoints.Items)
		for _, row := range items {
			entry := byID[row.MatchPlayerID]
			config, ok := lookup[normalizeName(row.Item)]
			if entry != nil && ok && itemCandidate(row.Item) {
				entry.result.Breakdown.Item += row.TotalUsed * configInt(config, "points", true)
			}
		}
	}

関連する処理:
分類と初期の対象候補:internal/itemfilter/manager.go 18–23行
手動分類・既定分類・候補収集:internal/itemfilter/manager.go 119–176行
使用数量からの加点:internal/points/logic.go 245–254行
名前の正規化と重複検証:internal/points/settings.go 548–591行

20 履歴・個人統計・所持品・移動と動画

3D移動履歴は同梱のmap3d.jsとmap3d-controls.jsを必要時に読み込み、WebGL 2で地図画像を平面に配置します。scenePositionで画像座標を描画座標へ変換し、保存済みpos_zと試合全体の最低記録高度を使って高さを表します。高度不足は基準面に置き、リスポーン・記録欠落をまたぐ線を描きません。2D切替、描画失敗時の2Dへの復帰、後始末を備えます。これは履歴表示であり、LiveAPIによる自由カメラ移動ではありません。

保存した元の記録と、見やすく加工した表示を分けて読むと、空白や数字の違いを理解できます。

履歴では試合概要、個人・チーム成績、戦闘・キル、武器、使用アイテム、リング、アビリティ、位置、観戦者と観戦先の切替、最後に把握した所持品を扱います。選手の識別値から過去の試合を辿り、選択試合を集計する経路もあります。

位置は受信イベントと記録間隔に応じてサンプリングするため、毎フレームの完全な軌跡ではありません。表示する移動履歴は、拾う・使う・アビリティ・攻撃など、最初の活動以降を対象にします。

死亡や全滅で活動区間を切り、蘇生やリスポーン直後に混ざる共有座標を避けて次の座標サンプルから再開します。最初の活動がない選手の座標が空でも、保存処理がすべて失敗したとは限りません。

所持品は最後に把握した状態です。全時点のインベントリを自由に巻き戻せる記録とは異なります。使用アイテムの統計も、所持数や拾った数ではなくuseの情報から読みます。

動画保存はブラウザで描いた地図を録画します。ゲーム映像や音声の録画ではなく、形式はブラウザの対応によりMP4を優先しWebMへ切り替えます。CanvasやMediaRecorderが利用できない場合は保存できません。

条件・注意点死亡中の区間を直線でつないで、瞬間移動や不正行為と判断しないでください。試合の削除は記録そのものを削除する操作で、大会からの除外とは別です。
実コードを見る:履歴・個人統計・所持品・移動と動画
実コードの抜粋Go · 15行 · 閲覧用

出典:internal/httpapi/matchdata_more.go 270–284行

ある時刻が表示対象の活動区間に入るかを判定する部分です。movementActiveIntervalの型、開始・終了境界を作る前段処理が必要です。座標の受信や動画録画そのものを行う関数ではありません。

func movementIntervalContains(interval movementActiveInterval, timestamp int64) bool {
	if timestamp < interval.StartTimestamp {
		return false
	}
	if timestamp == interval.StartTimestamp && !interval.StartInclusive {
		return false
	}
	if interval.EndTimestamp == nil {
		return true
	}
	if timestamp < *interval.EndTimestamp {
		return true
	}
	return interval.EndInclusive && timestamp == *interval.EndTimestamp
}

関連する処理:
試合と詳細記録の取得:internal/httpapi/matchdata.go 13–98行
活動開始・活動区間と座標表示:internal/httpapi/matchdata_more.go 104–303行
観戦・所持品・過去試合・削除:internal/httpapi/matchdata_more.go 395–453行
地図動画の形式と記録開始:webassets/static/js/dashboard.js 6365–6514行

21 CSV・JSON出力と、試合・大会の移行

結果を共有する出力と、別環境へ関連データを持っていく完全JSONでは保存範囲が違います。

結果のCSV/JSONは順位などを共有する出力です。試合の完全JSONは選手、戦闘、武器、使用品、位置、リング、アビリティ、観戦、所持品、ポイント、賞金首、AI分析などの関連データをまとめます。大会の完全JSONは大会情報・ルール・賞金首設定も加えます。

単体試合の出力では元の大会所属を取り除きます。大会ごと移すときは大会出力を選びます。ただし完全JSONでも開始予約、予約チャット、アプリの全設定、ライセンス、データフォルダー全体までは含みません。

取り込みはUUIDで同じ試合かを判断し、既存試合を上書きしません。新しい内部IDへ付け直し、関連する選手IDなども結び直します。大会の取り込みでは既存の同じ試合が無所属なら関連付け、別大会所属ならスキップします。

取り込んだ大会を自動でアクティブにはしません。元が完了済みなら完了済み、それ以外は下書きになります。保存はトランザクション単位で、途中の失敗を成功として扱いません。

取り込み時の形式と上限
対象検証する範囲
形式apex_custom_matches / apex_custom_tournament、version 1
試合数1回250試合まで。大会は試合0件でも取り扱える。
選手数1試合500選手まで。
関連テーブルの行数1試合内の各子テーブル10万行まで。
関連データの総量取り込む全試合を合わせた子データ25万行まで。
条件・注意点進行中・保護対象の試合は出力できません。指定先へのファイル保存は同名を上書きせず、フォルダーの存在も確認します。出力には選手名・識別値・成績が含まれるため、公開範囲を確認してください。
実コードを見る:CSV・JSON出力と、試合・大会の移行
実コードの抜粋Go · 14行 · 閲覧用

出典:internal/migration/matches.go 305–318行

試合UUIDが既にあるかを調べ、存在すれば新規挿入せずスキップ数を増やす部分です。トランザクション、entries、result、存在確認関数などは周辺処理で用意します。

	result := ImportResult{Skipped: []map[string]any{}}
	err := db.WithTx(ctx, func(tx *sql.Tx) error {
		cache := map[string][]string{}
		for _, entry := range entries {
			uuid := strings.TrimSpace(entry.Match["match_uuid"].(string))
			var existing int64
			err := tx.QueryRowContext(ctx, "SELECT id FROM matches WHERE match_uuid=?", uuid).Scan(&existing)
			if err == nil {
				result.Skipped = append(result.Skipped, map[string]any{"match_uuid": uuid, "map": entry.Match["map"], "reason": "既に存在するマッチです"})
				continue
			}
			if !errors.Is(err, sql.ErrNoRows) {
				return err
			}

関連する処理:
形式・上限・対象データ:internal/migration/matches.go 19–39行
出力の対象と所属除去:internal/migration/matches.go 91–166行
検証・重複判定・ID再割当:internal/migration/matches.go 168–407行
大会の完全JSONと取り込み:internal/httpapi/tournaments.go 1043–1211行

22 マップ画像・座標変換・キャリブレーション

試合のマップ選択ではなく、保存された座標を地図上のどこに描くかを決めます。

内部のマップ名へ表示名と画像ファイルを結び付けます。試合用のPlaylistカタログとは別で、標準画像や独自画像を、履歴などの描画に使用します。

補正ではゲーム座標と画像上の基準点から6係数を求め、画像X=a×ゲームX+b×ゲームY+c、画像Y=d×ゲームX+e×ゲームY+fとして変換します。拡大縮小・回転・平行移動を表現できます。

保存する基準点は3点以上です。複数点から係数を計算し、プレビューしてから保存します。直線上などに偏る点を避け、離れた位置で一致を確認すると、変換が不安定になりにくくなります。

補正がない場合は標準の座標範囲を使い、画像の下方向に合わせてY軸を反転します。サーバーは有限の係数や基準点を検証し、未登録のマッピングや不正な値を拒否します。表示用の変換であり、過去に受信した座標を書き換える機能ではありません。

条件・注意点フロント側に1点・2点から計算する処理があっても、現在の保存条件は3点以上です。画像の割当と、リング記録のある参照試合を先に確認してください。
実コードを見る:マップ画像・座標変換・キャリブレーション
実コードの抜粋JavaScript · 14行 · 閲覧用

出典:webassets/static/js/dashboard.js 5535–5548行

補正係数があれば式を使い、なければ標準範囲から画像座標へ変換します。画像寸法・境界・補正を受け取る関数で、補助関数の定義も必要です。

function gameToMapCoords(x, y, mapName, imageWidth, imageHeight, affineOverride = null) {
    const affine = getMapAffine(mapName, affineOverride);
    if (affine) {
        return {
            x: affine.a * x + affine.b * y + affine.c,
            y: affine.d * x + affine.e * y + affine.f
        };
    }
    // Fallback to legacy mapBounds
    const bounds = getMapBounds(mapName);
    const mapX = ((x - bounds.minX) / (bounds.maxX - bounds.minX)) * imageWidth;
    const mapY = ((bounds.maxY - y) / (bounds.maxY - bounds.minY)) * imageHeight; // Y is inverted
    return { x: mapX, y: mapY };
}

関連する処理:
画像・マッピング・補正保存の検証:internal/httpapi/matchdata_more.go 653–823行
描画座標への変換:webassets/static/js/dashboard.js 5535–5548行
係数の計算と正規化:webassets/static/js/dashboard.js 5689–5771行

23 試合ライフサイクル・中断復旧・保存失敗

終了の受信と保存完了の間に、遅れて届く情報と失敗時の保護を扱います。

試合は開始、取り込み中、終了待機、保存中、保存失敗などの状態で管理します。終了イベントの後は約1秒待ち、遅れて届いた情報も取り込んでから選手・戦闘・位置・所持品・ポイントなどを確定保存します。

通常の受信時は復旧用の途中保存を5秒より短い間隔で繰り返さないようにし、開始・終了の境目では直ちに保存を試みます。受信に応じた間隔制限であり、独立した5秒ごとの全体バックアップではありません。

同じ開始時刻・サーバー・マップの開始通知は重複扱いです。前試合の終了より次試合の開始が先に来ても、前の記録を保存してから新しい試合へ進みます。

正常終了や次回起動時には未終了のスナップショットをInterruptedとして保存します。保存失敗では失敗状態と復旧用記録を残し、破損・過大な復旧ファイルは隔離します。復旧ファイルの上限は64MiBです。

DBの保存では試合UUIDとIDを照合し、関連データをまとめて置き換えます。SQLiteのWAL方式を使うため、起動中のDB本体だけをコピーして完全なバックアップと考えないでください。記録の保存完了と正常終了を確認して、データフォルダー全体を保管します。

内部状態と読み方
状態意味
inactive処理中の試合がない。
active試合の出来事を取り込み中。
ending終了後に遅れて届く情報を待っている。
finalizing関連データと点数を確定保存中。
finalize_failed保存に失敗した。終了済み成功とは扱わない。
条件・注意点途中保存は直近試合の記録を守る仕組みで、全体の世代バックアップではありません。1秒を超えるすべての遅延や、停止中の出来事、同じ試合への継続接続を復元する保証もありません。
実コードを見る:試合ライフサイクル・中断復旧・保存失敗
実コードの抜粋Go · 15行 · 閲覧用

出典:internal/matchstate/session.go 346–360行

残ったスナップショットを読み、未終了の場合だけInterruptedへ変えて保存する部分です。Session、復旧ストア、persist・restoreFailedなどの周辺処理が必要です。停止中のイベントを取り直す処理ではありません。

func (s *Session) Recover(ctx context.Context) error {
	snapshot, err := s.recovery.Load()
	if err != nil || snapshot == nil {
		return err
	}
	if snapshot.EndTimestamp == 0 {
		snapshot.EndTimestamp = time.Now().Unix()
		snapshot.EndState = "Interrupted"
	}
	if err := s.persist(ctx, snapshot); err != nil {
		s.restoreFailed(snapshot)
		return err
	}
	return s.recovery.Clear(snapshot.MatchUUID)
}

関連する処理:
開始・終了・保存失敗・中断復旧:internal/matchstate/session.go 141–360行
途中保存の間隔:internal/matchstate/session.go 395–405行
試合データの確定保存:internal/matchstate/session.go 511–598行
復旧ファイルの保存・読み込み:internal/matchstate/recovery.go 28–92行
保存方式とスキーマ初期化:internal/database/database.go 50–89行

24 設定ファイル・通常ログ・イベントログ

何を保存するファイルなのかで、保持やバックアップの意味が変わります。

同梱の既定設定は、必要なファイルがないときに用意するものです。既存の設定を起動のたびに初期値へ戻す処理ではありません。設定は機能別ファイルやDBに分かれ、一部は同じフォルダーの一時ファイルへ書いてから置き換えます。

通常ログはアプリ全体の動作やエラーを記録します。試合イベントログはゲームから受けた出来事をJSONLの1行ずつで記録し、試合開始前にも最大200イベントをためておきます。

イベントログには30日超や新しい順200ファイルより後を整理する処理があります。ただし作成直後の件数まで厳密に200以下とする仕組みではありません。これはDBの試合履歴を30日で削除する規則でもありません。

復旧スナップショット、DB、設定、ログは用途が異なります。バックアップは正常終了後にデータフォルダーをまとめて保管し、個人情報や秘密情報を含み得るログや設定をそのまま公開しないでください。

実コードを見る:設定ファイル・通常ログ・イベントログ
実コードの抜粋Go · 14行 · 閲覧用

出典:internal/rawlog/logger.go 133–146行

イベントをJSONに変換し、改行を加えてバッファをFlushする部分です。書込み先やLoggerの初期化、情報の伏せ字処理は前後にあります。エラーチェックの流れを読むための抜粋で、ログを公開してよいという意味ではありません。

func (l *Logger) writeLocked(entry map[string]any) error {
	data, err := json.Marshal(entry)
	if err != nil {
		return err
	}
	if _, err := l.writer.Write(append(data, '\n')); err != nil {
		return err
	}
	if err := l.writer.Flush(); err != nil {
		return err
	}
	l.count++
	return nil
}

関連する処理:
初期設定の用意と保存先:internal/appconfig/config.go 120–174行
JSON設定の一時ファイル書込み:internal/httpapi/legacy.go 740–771行
イベント受信と開始前バッファ:internal/rawlog/logger.go 51–86行
JSONL書込みと保持整理:internal/rawlog/logger.go 133–191行

25 Discord投稿・送信先・オートスタート

同じDiscord連携でも、通常投稿、AI分析、外部Bot連携は別の送信経路です。

通常のDiscord送信は参加コード、任意文、大会総合順位、マッチ結果を画面で組み立て、送信先とプレビューを確認してから投稿します。大会は選択中を優先し、なければアクティブ大会を使います。

チャンネル候補はBotから取得できるテキスト・アナウンスチャンネルです。ID・メンション・重複しない名前から送信先を解決し、同名で曖昧な場合は自動で選びません。大会の公開カスタムに設定した通知先が優先される経路もあります。

AI分析の送信は保存された分析を長文分割し、AI分析用チャンネルへ送る別の経路です。通常結果の送信先を変えただけで、すべての自動投稿先が変わるとは限りません。

オートスタート用Botの設定がある場合には、参加コードを取得したときにもBot向けコマンドを自動投稿します。この外部Bot連携は、アプリ内の日時指定による開始予約とは別です。参加コードの表示成功だけでは、外部Botの受信成功も確認できたことにはなりません。

参加コード取得は、通常のコード送信プレビューからも呼ばれます。オートスタートBot向けの設定がある場合は、通常投稿の確認前でも別のBot向け投稿が起こり得ます。コードが表示されたことだけでは、そのBot向け投稿の成功まで確認できません。

条件・注意点テストでも実際のチャンネルへ投稿されます。用途別の送信先、Bot権限、設定に応じた自動投稿を確認し、参加コードと秘密の認証情報を取り違えないでください。
実コードを見る:Discord投稿・送信先・オートスタート
実コードの抜粋Go · 14行 · 閲覧用

出典:internal/httpapi/integrations.go 630–643行

名前で一致する候補を探し、複数あれば曖昧なまま選ばず空結果を返します。前段で読み込んだチャンネル一覧と入力値、補助関数が必要です。

	wanted := strings.TrimPrefix(strings.ToLower(value), "#")
	found := ""
	cache, _ := settings["channels_cache"].([]any)
	for _, raw := range cache {
		item, _ := raw.(map[string]any)
		if strings.ToLower(textOrEmpty(item["name"])) == wanted {
			if found != "" && found != textOrEmpty(item["id"]) {
				return ""
			}
			found = textOrEmpty(item["id"])
		}
	}
	return found
}

関連する処理:
チャンネル取得・解決・投稿:internal/httpapi/integrations.go 564–676行
参加コード取得と外部Bot連携:internal/httpapi/legacy.go 162–218行
分析結果の送信:internal/httpapi/matchanalysis.go 654–685行
大会と通知先の選択:webassets/static/js/dashboard.js 11377–11425行

26 自動カメラ・手動切替後の休止・確認待ち

チーム単位の交戦維持、直近の与ダメージ、鮮度付き座標、切替確認を組み合わせます。

最初の候補は攻撃側または被攻撃側です。combatEventLockedは、正の敵チーム間ダメージを確認したら、確認待ち・休止・最小ダメージの判定より先にrecordCombatLockedで両チームの活動時刻を更新します。観戦チームの最後の与ダメージ・被ダメージから4秒で待機表示、15秒未満は無関係な交戦への切替を抑止します。15秒後もタイマーだけでは切り替えず、新しいダメージイベントで判断します。

手動で視点を切り替えた後は、設定した間隔だけ通常の自動切替を休止します。ただし観戦中の選手がノックされた場合は、同じチームの生存・接続済み・非ノックの代替選手を探す別の処理が働き、休止時間を待たず切り替える場合があります。

切替要求の送信成功だけでは確定とせず、観戦者切替イベントを受けて結果を確認します。確認待ちの間は複数要求を重ねず、3秒以内に確認できない場合はエラー状態になります。

初期状態はOFF、別チームへの最短間隔8秒、最小ダメージ1です。設定範囲は間隔1〜60秒、ダメージ1〜500。同チームの通常切替は直近の確認から2秒以上、現在の選手の最後の攻撃から2秒以上を必要とし、直近2秒以内に基準以上のダメージを出した味方から選びます。候補は攻撃中かを優先し、5秒以内の座標がある候補を優先したうえで交戦相手への3次元距離を比較します。座標不足時は名前・識別子の順、ダウン時の候補は部隊内番号・名前・識別子の順で安定させます。

OBSの観戦者指定はどの視点の情報を表示するかの指定です。ゲームの観戦対象を変える自動カメラとは別で、OBS表示元を選んだだけで自動切替が有効になるわけではありません。

条件・注意点接続、候補の生存状態、観戦の準備、ゲーム側での反映が必要です。座標は選手選びにのみ使い、上空移動・自由カメラ旋回は行いません。欠損・非有限・古い座標は距離比較に使わず、遮蔽も推定しません。キャッシュは最大256選手で、試合開始・終了・無効化時に初期化します。死者・切断・ノック候補を除外し、未確認の送信で観戦チームを確定しません。
実コードを見る:自動カメラ・手動切替後の休止・確認待ち
実コードの抜粋Go · 16行 · 閲覧用

出典:internal/httpapi/autocamera.go 293–308行

同じチームの代替候補を絞り込む前半です。現在の対象や生存・切断・ノック状態を確認します。後段の並べ替えと実際の切替送信は含みません。

func (a *autoCameraManager) replacementPlayerLocked(victimHash, victimName string, combatTeam int64) (autoCameraPlayer, bool) {
	candidates := make([]autoCameraPlayer, 0)
	for _, player := range a.roster() {
		hash := strings.TrimSpace(player.Hash)
		name := strings.TrimSpace(player.Name)
		if !player.Alive || player.Disconnected || player.TeamID <= 1 || hash == "" && name == "" ||
			a.combatTeamID(player.TeamID) != combatTeam || sameAutoCameraPlayer(hash, name, victimHash, victimName) ||
			a.isPlayerDownedLocked(hash, name) {
			continue
		}
		player.Hash, player.Name = hash, name
		candidates = append(candidates, player)
	}
	if len(candidates) == 0 {
		return autoCameraPlayer{}, false
	}

関連する処理:
既定値と設定検証:internal/httpapi/autocamera.go 82–188行
イベントと代替候補:internal/httpapi/autocamera.go 229–324行
確認待ち・戦闘判定・要求送信:internal/httpapi/autocamera.go 393–565行

実コードを見る:4秒・15秒の状態判定

状態を返す部分の抜粋です。設定済みの観戦チームと活動時刻、固定時間の定義が必要で、単独では実行できません。別チームへの送信判断はcombatEventLockedで別に行います。

出典:internal/httpapi/autocamera_stability.go 44–60行

func (a *autoCameraManager) engagementStatusLocked(now time.Time) (string, float64) {
	if !a.active || !booleanValue(a.settings["enabled"]) || a.observedTeamID <= 0 {
		return "searching", 0
	}
	if now.Before(a.manualHoldUntil) {
		return "manual_hold", a.manualHoldUntil.Sub(now).Seconds()
	}
	elapsed := now.Sub(a.lastTeamActivityLocked())
	if elapsed >= engagementReleaseAfter {
		return "ready", 0
	}
	remaining := (engagementReleaseAfter - elapsed).Seconds()
	if elapsed >= engagementIdleAfter {
		return "waiting", remaining
	}
	return "combat", remaining
}

27 AI接続・モデル・プロンプト・分析ジョブ

記録の整形、生成、保存、共有は段階を分けて実行します。

AI分析は選んだ接続方法・モデル・推論設定で、保存された試合記録を分析します。Codexを使う場合は別途CLIとログイン状態が必要で、OpenAI API・Anthropic APIを使う方法もあります。使えるモデルは接続先から確認し、固定の一覧が常に利用できるとは案内しません。

「プロンプト確認」は指示と入力データの表示です。実行するとアプリ内のバックグラウンドジョブを開始し、同じ試合ですでに分析中なら処理を重ねません。分析画面を閉じてもアプリが動いていれば継続します。

入力には選手・チーム成績、戦闘、武器、リング、使用アイテム、アビリティなどを含みますが、件数に上限があります。戦闘は時刻順の先頭から20〜500件、既定180件で、全ログや試合映像を無制限に分析するわけではありません。

生成には15分のタイムアウトがあり、失敗・空の回答・保存失敗は失敗状態になります。成功時は分析文、モデル、生成日時などを保存してから、任意のDiscord自動送信へ進みます。共有の失敗と、分析の生成・保存の失敗を混同しないでください。

表示に残る「バックグラウンドモード」の値だけで、Go側の分析が別の処理経路になるとは限りません。いずれもアプリ内では非同期に進める処理として扱い、状態と保存された結果を確認します。

モデル一覧の取得はCodex側の接続で使う機能です。APIを使う接続先では、利用可能なモデル名を設定します。接続方式ごとに利用条件が異なり、一覧取得・文章生成・Discord送信を同じ接続テストで保証するものではありません。

分析入力の代表的な件数上限
記録上限
戦闘設定で20〜500件、既定180件
武器 / 使用アイテムそれぞれ上位20件
リング別アイテム / 選手別アイテム80件 / 120件
アビリティ100件
賞金首機能が有効な場合だけ入力へ追加
条件・注意点外部AIへ選手情報や成績が渡ります。送信データと共有先を事前に確認してください。分析は記録を読む補助であり、記録にない出来事の断定や不正判定の根拠にはしません。
実コードを見る:AI接続・モデル・プロンプト・分析ジョブ
実コードの抜粋Go · 15行 · 閲覧用

出典:internal/httpapi/matchanalysis.go 271–285行

同じ試合の分析中状態を確認し、新しいジョブを登録して非同期処理へ渡す関数です。analysisManagerとrun本体などが必要です。公開ページからAIに接続するものではありません。

func (m *analysisManager) begin(server *Server, matchID int64, payload map[string]any) map[string]any {
	m.mu.Lock()
	if existing := m.jobs[matchID]; existing != nil && (existing.Status == "starting" || existing.Status == "queued" || existing.Status == "in_progress") {
		result := map[string]any{"background": true, "job": publicAnalysisJob(existing)}
		m.mu.Unlock()
		return result
	}
	now := time.Now().Format("2006-01-02T15:04:05")
	job := &analysisJob{MatchID: matchID, Status: "starting", StartedAt: now, UpdatedAt: now}
	m.jobs[matchID] = job
	settings := cloneMap(m.settings)
	m.mu.Unlock()
	go m.run(server, job, payload, settings)
	return map[string]any{"background": true, "job": publicAnalysisJob(job)}
}

関連する処理:
ジョブの重複抑止と保存順:internal/httpapi/matchanalysis.go 253–358行
プロンプト・生成・共有:internal/httpapi/matchanalysis.go 575–685行
入力範囲と指示:internal/httpapi/matchanalysis.go 687–850行

28 AIとの会話・大会下書き・作成確定

AIへの相談と、実際の大会・予約を保存する操作を分離します。

大会作成には試合分析と共通のAI接続設定を使います。大会作成だけ別の接続先が自動で用意されるわけではないため、先にAI設定を確認します。

AIへ渡すのは会話履歴だけでなく、現在の下書きと登録済みモード・マップのカタログです。フォームで直した内容を次の会話にも反映し、AI出力だけを無条件に正解として扱いません。

返ってきた大会名、設定、試合予定、案内文を正規化し、不足や不整合を警告して編集用の下書きにします。通信や回答形式に問題があれば、現在の下書きを返して編集を続けられるようにします。

「この内容で作成」のときに厳しく検証し、大会・開始予定・予約チャットをまとめて保存します。会話しただけでは登録されず、フォームと詳細JSONに同じ項目がある場合は収集時にフォーム側を優先します。

最大30試合、各試合20チャットで、大会名120文字、説明2000文字、チャット500文字までです。日時の重複、未登録Playlist、時刻条件などを確認します。事前適用は0〜120分、相対チャットは0〜1440分前を扱います。

作成時にアクティブ化を選ぶと既存のアクティブ大会を解除して切り替えます。試合複製の30分加算は日付を自動で翌日へ進めないため、深夜の予定は日付も確認します。

条件・注意点下書きを画面で編集できることと、ブラウザを閉じても永続保存されることは別です。確定前に予定、ポイント、アクティブ化、案内文を人が確認してください。
実コードを見る:AIとの会話・大会下書き・作成確定
実コードの抜粋Go · 16行 · 閲覧用

出典:internal/httpapi/tournament_ai_builder.go 203–218行

AIとの通信や回答の読み取りに失敗したとき、現在の下書きと警告を返す部分です。ctx・instructions・prompt・currentDraft・currentWarningsや周辺処理が必要です。失敗を無視して大会を確定するものではありません。

	output, provider, err := s.analysis.generateAITournamentBuilderText(ctx, instructions, prompt)
	if err != nil {
		writeJSON(w, http.StatusBadGateway, map[string]any{
			"success": false, "error": "AIとの通信に失敗しました: " + err.Error(),
			"draft": currentDraft, "warnings": currentWarnings,
		})
		return
	}
	root, err := decodeAIJSONObject(output)
	if err != nil {
		writeJSON(w, http.StatusBadGateway, map[string]any{
			"success": false, "error": "AIの回答を構成データとして読み取れませんでした: " + err.Error(),
			"draft": currentDraft, "warnings": currentWarnings,
		})
		return
	}

関連する処理:
下書き生成と作成確定:internal/httpapi/tournament_ai_builder.go 155–329行
内容の正規化と検証:internal/httpapi/tournament_ai_builder.go 331–530行
フォーム収集・複製・確定:webassets/static/js/dashboard.js 10329–10670行

29 外部カスタム・VC・タイムラインの反映

外部サービスの状態変更と、アプリ内の大会・試合開始を区別します。

カスタム連携は外部サービスから情報を取り込み、ローカルの大会や開始予定と関連付けます。カスタムの開始・停止・終了済み変更は、連携先への操作と、必要なVC割当・復元の依頼であり、Apexの試合開始や強制終了そのものではありません。

タイムラインは音声、マップ、マッチ開始のカードを持ちます。カード順でマップと後続の試合を対応付け、実際の実行は設定日時に従います。マップカードは後続試合の事前適用へ変換され、単独のマップ予約になるわけではありません。

試合開始カードを含めて再反映すると、同じカスタムに由来する既存の開始予定と関連する予約チャットを置き換えます。別カスタムまで一括削除するものではありません。音声だけの反映では既存の開始予定を消さないため、最後の試合予約まで削除したい場合は「自動進行」→「開始時刻・予約チャット」の一覧で削除結果を確認します。

マップカードの直後の試合では時刻差を事前適用分へ変換し、同じマップを引き継ぐ次試合は既定2分前を使います。時刻差の計算と日付の扱いが同じではないため、日付をまたぐ予定やカード順と時刻の矛盾には注意します。

条件・注意点再反映では、同じカスタムの手直し済みの予約チャットも置き換えられる場合があります。カードを編集・削除しただけで予定が更新されたと考えず、反映後の開始予定と音声予定をそれぞれ確認してください。
実コードを見る:外部カスタム・VC・タイムラインの反映
実コードの抜粋Go · 9行 · 閲覧用

出典:internal/httpapi/custom_schedule.go 440–448行

対象カスタムに由来する設定だけを除き、別カスタムなどの設定を残す関数です。カスタム名の変数と、設定配列の形式を前提とします。予定の削除を実行するサンプルではありません。

func filterCustomOwned(items []map[string]any, customName string) []map[string]any {
	result := make([]map[string]any, 0, len(items))
	for _, item := range items {
		if textOrEmpty(item["source_custom"]) != customName {
			result = append(result, item)
		}
	}
	return result
}

関連する処理:
外部カスタムの状態操作:internal/httpapi/integrations.go 330–363行
予定と音声設定への反映:internal/httpapi/custom_schedule.go 20–297行
同じカスタムに由来する設定の区分:internal/httpapi/custom_schedule.go 440–448行
タイムラインの対応付け:webassets/static/js/dashboard.js 17218–17324行

30 音声ファイル・テンプレート・予約・終了トリガー

音声の生成機能ではなく、外部サービスに登録した音声の再生を依頼します。

外部サービスの音声ファイルやテンプレートを選び、再生、停止、イントロなどを依頼します。再生先はサービス側で決まり、アプリのPC内のファイル選択やローカルスピーカー再生を保証する機能ではありません。

時刻予約は日付を持たず、動作中に毎秒確認して該当時刻を検出します。不要な予定を残すと翌日も対象になり、開始予約の120秒猶予とは違う仕組みです。

同じ起動中はIDと日付で実行済みを管理しますが、実行済みマーカーは再生依頼の前に付くため、失敗すれば自動で何度も再生を試すとは限りません。管理はメモリ上なので、再起動をまたぐ厳密な一度だけの再生保証もありません。

試合終了時、勝者なしならリマッチ用の音声だけを扱い、勝者ありなら試合カウンターを進め、共通終了・試合番号別・最終試合の音声を判定します。最終試合番号がない古い設定では再同期が必要です。

自動トリガーと時刻予約ではループを無効にします。テスト再生は実際に外部サービスで音が出る操作で、ループの扱いも別です。開始トリガーの設定キーが残っていても現在のイベント入口では処理しないため、開始前の案内は予約で確認します。

条件・注意点ファイル・テンプレート一覧が空でも、登録なしだけでなく取得エラーの可能性があります。接続を確認してください。不要な予定は無効化・削除し、対象カスタムの再反映結果も確かめます。
実コードを見る:音声ファイル・テンプレート・予約・終了トリガー
実コードの抜粋Go · 17行 · 閲覧用

出典:internal/httpapi/integrations_runtime.go 118–134行

勝者なしの終了と、勝者ありの終了を分ける前半です。勝者なしでは試合カウンターを進めません。設定、マネージャー、外部再生処理などが必要な抜粋です。

func (m *integrationManager) audioMatchEnd(hasWinners bool) {
	settings := m.audio.snapshot(false)
	if !booleanValue(settings["enabled"]) {
		return
	}
	if !hasWinners {
		trigger, _ := settings["rematch_trigger"].(map[string]any)
		if trigger != nil && booleanValue(trigger["enabled"]) {
			copy := cloneMap(trigger)
			m.spawn(func() { _, _ = m.executeAudioTrigger(copy, audioDelay(copy, 0), 0) })
		}
		return
	}
	m.mu.Lock()
	m.audioCounter++
	matchNumber := m.audioCounter
	m.mu.Unlock()

関連する処理:
一覧・テスト再生・停止・予約保存:internal/httpapi/integrations.go 419–511行
時刻検出・終了時の条件・再生依頼:internal/httpapi/integrations_runtime.go 68–208行

31 公開結果・現在ルールでの再計算・再送

保存した大会の結果を組み立て直し、外部の公開ページへ送る機能です。

終了時刻と勝者がある大会所属試合を集め、現在の大会ポイント設定で再計算します。新しく終わった1試合の差分だけでなく、対象大会の試合別・累計の結果を再構成して送ります。

自動処理は試合保存後、その時点のアクティブ大会を対象とします。大会の公開カスタム指定を優先して送信先を決めるため、試合保存時の所属先と、送信処理で選ぶ大会の状態をあわせて確認します。

送るのは選手・チームの成績、得点内訳、使用アイテム、大会累計などです。試合映像やアプリ設定一式ではありません。手動の対象指定がなければアクティブ大会を優先し、なければ保存された未公開対象を参照します。

送信は各回15秒で打ち切り、失敗時は5秒間隔で最大2回再試行します。未公開の対象と最後のエラーを保持し、原因を解消して手動で再送できます。再送時にも対象大会と公開先を確認します。

条件・注意点公開結果・マッチレポート・AI分析・OBSの最新試合は、対象データと条件が同一ではありません。公開送信の失敗で、ローカルに保存済みの試合まで失われたと判断しないでください。
実コードを見る:公開結果・現在ルールでの再計算・再送
実コードの抜粋Go · 14行 · 閲覧用

出典:internal/httpapi/public_results.go 74–87行

未公開対象を記録し、成功したら未公開状態を解消して成功日時を残す2つの関数です。公開状態のマネージャーや設定保存処理が必要で、実際の公開送信そのものは省いています。

func (m *integrationManager) markPublicPending(customName string, tournamentID int64) {
	m.updatePublicStatus(map[string]any{
		"pending": true, "pending_tournament_id": tournamentID,
		"pending_custom_name": customName, "last_error": "",
	})
}

func (m *integrationManager) markPublicSuccess(customName string) {
	m.updatePublicStatus(map[string]any{
		"pending": false, "pending_tournament_id": nil, "pending_custom_name": "",
		"last_error": "", "last_published_at": time.Now().Format("2006-01-02T15:04:05"),
		"last_custom_name": customName, "last_via": "api",
	})
}

関連する処理:
試合保存後の連携:internal/httpapi/background.go 18–33行
状態保存・手動と自動の対象選択:internal/httpapi/public_results.go 33–192行
公開先・再試行・送信内容の構成:internal/httpapi/public_results.go 219–402行

32 外部接続設定と、手動・自動マッチレポート

送信方式によって、適用する設定や履歴の残し方が違います。

「高度な設定」→「外部サービス・AI」のカスタム接続を保存すると、音声・レポート向けの接続設定にも反映されます。「保存して取得テスト」は設定保存の後に実際の情報取得を行い、条件を満たす連携の画面が表示されます。

マッチレポートは試合概要・チーム・選手・保存済みポイントなどを対応サービスへ送ります。AIの分析文でも、現在の大会ルールで作る公開順位でもありません。プレビューは送信内容を見る操作で、送信確定とは別です。

自動送信は初期OFFで、既定では保存後3秒待ち、失敗時は2回・5秒間隔で再試行します。ポイントを含めるか、リーグID、待機・再試行設定をこの経路で使い、HTTP 4xxでは再試行を止めます。

手動の単体・大会送信では、現在の処理は自動送信のinclude_points・league_id・再試行設定を同様には適用せず、保存済みポイントを含む内容を送ります。大会送信は所属全試合をまとめるため、公開結果の勝者あり条件を流用しません。

自動送信と手動単体送信は履歴を残しますが、手動の大会一括送信はローカル送信履歴に記録しません。大会一括は送信直後に返る結果と連携先で確認します。自動処理は直近100履歴へ整理し、履歴画面は50件を表示します。

送信方式ごとの違い
方式設定と点数確認場所
手動の単体送信保存済みポイントを含む。自動送信オプションは同様には適用しない。プレビュー、送信応答、履歴
手動の大会一括送信所属試合をまとめて送る。公開結果の対象条件とは別。プレビュー、送信応答、連携先。ローカル履歴には残さない。
自動送信待機、ポイント含有、リーグID、再試行を設定から使用。送信履歴と連携先
条件・注意点接続先変更時は新しい接続情報を明示して確認します。認証や権限などの4xxエラーは、待つだけでは直らない場合があります。公開結果への反映は別の状態表示でも確認してください。
実コードを見る:外部接続設定と、手動・自動マッチレポート
実コードの抜粋Go · 13行 · 閲覧用

出典:internal/httpapi/background.go 72–84行

自動送信で成功したら終わり、4xxなら再試行を止め、その他の失敗では次の試行前に待つ部分です。事前の送信結果、設定、履歴記録などに依存します。

	for attempt := 0; attempt <= retries; attempt++ {
		lastResult, lastStatus, lastErr = s.externalCallWithKeyPolicy(ctx, settings, http.MethodPost, "/match-report", payload, false)
		if lastErr == nil {
			s.recordReportHistory(matchID, true, lastStatus, lastResult)
			return
		}
		if lastStatus >= 400 && lastStatus < 500 {
			break
		}
		if attempt < retries && !s.waitForBackground(retryDelay) {
			return
		}
	}

関連する処理:
接続設定の反映:internal/httpapi/integrations.go 253–270行
保存点からのレポート・手動送信・履歴:internal/httpapi/integrations.go 693–826行
自動送信オプションと再試行:internal/httpapi/background.go 35–98行

33 ライセンス・秘密情報・外部接続の境界

利用状態の確認と、運営データや接続情報を安全に扱うための仕組みです。

認証状態と有効期限は起動時だけでなく利用中も確認します。無効・期限切れなどの状態は、管理機能やApexとの接続に影響します。画面を表示できることと、機能の利用が許可されていることは別です。

通信障害時の一時的な猶予には、同じ起動中に有効な認証が確認できていることなどの条件があります。既定の6時間という値を、オフライン起動や必ず6時間利用できる保証として扱わないでください。

秘密の設定値は通常の表示では伏せられ、Windows側の保護機構を利用して保存します。別PCや別Windowsユーザーへ設定ファイルをコピーしても、そのまま使えるとは限りません。移行後に接続情報を入力し直す場合があります。

外部サービスのURLを変更した際は、新しい接続先のキーを明示して保存します。古い接続先のキーが勝手に引き継がれる前提にしないでください。試合JSONにも選手の識別値や成績が含まれるため、秘密設定がなくても公開範囲の確認は必要です。

条件・注意点この章は動作と運用上の注意だけを説明します。認証情報、署名鍵、ライセンス判定の内部コード、制限を回避する手順は掲載しません。

関連する処理:
公開状態・有効性の確認:internal/license/manager.go 98–165行
設定保護の担当箇所:internal/securestore/settings.go 9–55行
外部接続設定の扱い:internal/externalapi/security.go 13–90行

34 更新・インストーラー・再起動

更新の取得、ファイル検証、記録を守る終了、インストーラー起動は別々の段階です。

更新情報のアプリ種別・配布チャネル・数値形式のバージョンを確認し、現在より新しい更新だけを受け付けます。文字列の大小ではなく、ドットで区切った各数値を順に比較します。

ダウンロードでは許可された同一オリジンのHTTPS、リダイレクトの禁止、サイズ、SHA-256などを確認します。配布ビルドに署名検証用の公開鍵が設定されている場合は、電子署名も確認します。すべてのビルドで同じ署名設定とは限りません。

検証後すぐに現在の処理を切り捨てるのではなく、LiveAPI・HTTP・試合保存・バックグラウンド処理を順に終了させます。終了処理でエラーが残る場合は、後続の再起動やインストーラー起動へ進みません。

通常の更新はセットアップEXEを使い、アプリ本体と外部マップ画像を更新します。設定・試合履歴は別のデータ領域に保持されます。ただし更新前のバックアップは必要で、単体のアプリEXEを配信する手順と混同しないでください。

条件・注意点試合中・終了処理中・保存に失敗している状態では、先に記録とエラーを確認します。ここでは更新保護を迂回する方法や、本番データへ修復コマンドを実行する方法は案内していません。
実コードを見る:更新・インストーラー・再起動
実コードの抜粋Go · 22行 · 閲覧用

出典:internal/updater/updater.go 258–279行

バージョンの各桁を最大4つの整数にして比較する部分です。たとえば2.0.10は2.0.9より新しく、2.0.1と2.0.1.0はこの比較では同じになります。呼び出し前の形式検査や公開情報の検証は省略しており、strings・strconvの読み込みも必要です。

func compareVersions(left, right string) int {
	parse := func(value string) [4]int {
		var out [4]int
		for i, part := range strings.Split(value, ".") {
			if i >= 4 {
				break
			}
			out[i], _ = strconv.Atoi(part)
		}
		return out
	}
	a, b := parse(left), parse(right)
	for i := range a {
		if a[i] < b[i] {
			return -1
		}
		if a[i] > b[i] {
			return 1
		}
	}
	return 0
}

関連する処理:
更新の検証と終了前の保護:internal/httpapi/application.go 52–125行
更新情報と取得ファイルの検証:internal/updater/updater.go 43–219行
安全な終了の順序:cmd/apexcustom/main.go 246–287行
本体・地図画像の配布:installer.iss 40–59行

35 開発時の構成・ビルド・テスト

公開しているサンプルと、アプリ本体を開発・配布する作業を区別します。

起動処理は cmd/apexcustom、通信と各機能は internal 以下、画面は webassets のテンプレート・JavaScript・CSSに分かれています。HTTPの処理が状態管理や集計を呼び、画面がその結果を表示する構造です。

確認したソースの go.mod はGo 1.27.0を要求します。配布用の build.ps1 は必要なGoの確認、全Goテスト、本体のビルド、埋め込みスキーマの確認、Inno Setup 6によるセットアップ作成を順に行います。ここで最新のツールチェーン一覧を案内しているわけではありません。

ApexのLiveAPIスキーマは実行ファイルへ埋め込みます。build.ps1 は見つかったApexの定義から記述子を更新・検証し、Apexを見つけられない環境では同梱の定義を使います。配布アプリが起動のたびに定義を自動更新する仕組みではありません。

回帰テストにはGoの各パッケージのテストと、Node.jsで動かす大会画面のテストがあります。公開サンプルの単独動作確認と、Apex・OBS・各連携先を使った結合確認は別です。実装上の入出力だけで、実際の試合や外部送信の成功を証明したとはいえません。

ローカルHTTPの内部処理には、試合の一時停止、準備状態、チーム名、スポーン位置、最終リングなどの補助操作もあります。ただし通常の管理画面からつながっていない処理があり、定義があることだけで「一般ユーザーが画面から使える機能」や「外部向けの安定したAPI」とは扱いません。Apex側の対応や権限も必要になるため、ここでは操作を実行する例は掲載しません。

条件・注意点このサイトでは、読みやすい短い抜粋と説明を公開しています。ソース一式の配布ページではありません。開発用の設定変更や検証には本番とは分けたデータ・送信先を用意し、実行内容を確認してください。

関連する処理:
依存関係と必要バージョン:go.mod 1–21行
配布用ビルドの流れ:build.ps1 201–317行
開発者向けの構成説明:README_GO.md 23–116行
画面と区別する補助操作:internal/httpapi/legacy.go 287–313行
チーム・配置などの補助操作:internal/httpapi/legacy.go 434–483行

共同運営:専用権限と試合開始スナップショット

2.0.5の共同運営は Go の collaboration*.go と Custom Management の custom_collaboration.py で実装しています。従来章のコード抜粋は2.0.4確認時点です。以下は2.0.5の設計説明であり、そのまま実行するコードではありません。

専用キーは X-Collaboration-Key で送信し、サーバーではSHA-256ハッシュで照合します。カスタム単位の境界と editor / recorder / viewer を検査し、全体管理 API のキーと混用しません。更新は expected_revision による比較で競合を409として拒否します。スキーマ版2は許可グループと入れ子の型・範囲・容量を制限します。

共有グループは point_settings、item_candidates、lobby_settings、camera_settings、roster、schedule。OBS認証やローカル接続情報は含めません。カスタム専用の actions は editor のみが開始/停止/完了/公開を既存処理へ渡せます。キー発行と端末移管は管理API側に限定します。

Session.WithIdle で MatchSetup と設定反映を直列化し、マッチ作成のトランザクションで tournament_shared_context から match_recording_context を生成します。後者は UPDATE 禁止トリガーで固定し、採点と再集計では RecordingPointSettings / RecordingItemFilter を使います。マッチ詳細には運営者・端末・担当・設定版を返しますが、一般公開結果へ端末識別情報は追加しません。

プロフィールごとに接続キーをsecurestoreで保持し、約10秒間隔の自動取得は古い接続先からの応答を適用しないよう識別子を確認します。DBとローカル設定ファイルを跨ぐ原子性は保証しないため、保存エラーは再反映と開始前チェックで扱います。旧形式マッチは後から記録担当を推定しません。相互比較と正式結果の採用は将来段階です。

ライセンス購入と情報送信(2.0.9)

月額100円・1台ライセンスの購入/契約管理はStripe経由です。決済後に表示されるキーを保存してください。毎月自動更新し、解約後も支払済み期間まで使えます。

初回認証・更新後の初回利用では、認証用PC構成(OS・CPU・メモリ・GPU・アプリ版)の必須送信について確認します。「ライセンスの確認・再認証」画面で任意診断のON/OFFを変更できます。任意診断は初期値OFFです。OFFでも認証用構成は送信されます。

任意診断は内部リクエストエラー時のエラー種別・関数名・匿名化したマッチ集計値を対象とし、生ログや選手名・固定ID・チャット・他サービスのAPIキーは送りません。全てのクラッシュの収集を保証するものではありません。認証構成は90日、診断は30日保持します。送信項目と保存先の詳細をご確認ください。

実装箇所:internal/license/privacy.go、privacy_windows.go、internal/matchstate/diagnostics.go、internal/httpapi/license.go。既存章の過去版コード抜粋はその確認時点の説明であり、この追補は2.0.9の送信条件を説明しています。