fortnite-replay-analysis 1.0.8 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,117 +1,112 @@
1
1
  # Fortnite Replay Analysis
2
2
 
3
- Fortniteのリプレイファイルを解析して、プレイヤーデータを取得・集計・ソートできるNode.jsのモジュールです。
3
+ FortniteのリプレイファイルをNode.jsで解析し、プレイヤーデータを取得・集計・ソートできるモジュールです。
4
4
 
5
5
  ## 特徴
6
6
 
7
- * OS判定してC#でビルドされた自己完結バイナリを呼び出すから、高速解析できる
8
- * botプレイヤーの除外や順位ソートなどオプション対応
9
- * 複数マッチのスコアをパーティ単位でマージして集計可能
10
- * 公式準拠でのスコアのソートも可能
7
+ * OS判定でビルド済みの自己完結バイナリを呼び出し、高速に解析できます。
8
+ * botプレイヤーの除外や順位ソートのオプションに対応しています。
9
+ * 複数マッチのスコアをパーティ単位でマージして集計できます。
10
+ * 公式準拠のルールでスコアをソートできます。
11
11
 
12
12
  ## インストール
13
13
 
14
- ```
14
+ ```bash
15
15
  npm install fortnite-replay-analysis@latest
16
16
  ```
17
17
 
18
18
  ## 使い方
19
19
 
20
+ 以下は、1試合のリプレイ解析からスコア計算、複数マッチのマージまでを実行する例です。
21
+
20
22
  ```js
21
- const { ReplayAnalysis, mergeScores, sortScores, calculateScore } = require('fortnite-replay-analysis');
23
+ const {
24
+ ReplayAnalysis,
25
+ calculateScore,
26
+ sortScores,
27
+ mergeScores
28
+ } = require('fortnite-replay-analysis');
22
29
 
23
30
  (async () => {
24
- try {
25
- // 1試合分のリプレイ解析(返り値はJSON形式)
26
- // rawPlayerData: 元の解析結果、生データ
27
- // processedPlayerInfo: bot除外や順位ソート済みのプレイヤーデータ
28
- const { rawPlayerData, processedPlayerInfo } = await ReplayAnalysis('./path/to/replayDir', { bot: false, sort: true });
31
+ // リプレイ解析(ディレクトリ指定時は最初に見つけた .replay を処理、ファイル指定時はそのファイルを使用)
32
+ const {
33
+ rawReplayData,
34
+ rawPlayerData,
35
+ processedPlayerInfo
36
+ } = await ReplayAnalysis(
37
+ './path/to/replayDirOrFile',
38
+ { bot: false, sort: true }
39
+ );
29
40
 
30
41
  console.log('Raw Data:', rawPlayerData);
31
42
  console.log('Processed Player Info:', processedPlayerInfo);
32
43
 
33
- // 解析結果のスコア配列を公式準拠のルールでソートも可能
44
+ // 公式ルールでソート
34
45
  const sortedScores = sortScores(processedPlayerInfo);
35
46
 
36
- // 複数マッチの解析結果をまとめたいときは、
37
- // sortScoresでソート済みの配列を複数用意して
38
- // mergeScoresに配列の配列として渡す
39
- const mergedScores = mergeScores([
40
- sortedScores, // 1試合目の結果
41
- sortedScores2, // 2試合目の結果
42
- // ...
43
- ]);
47
+ // ポイント&キル計算
48
+ const score = await calculateScore({
49
+ matchData: processedPlayerInfo,
50
+ points: { 1: 11, 2: 6, 3: 5, 4: 4, 5: 3, 6: 2 },
51
+ killCountUpperLimit: 10, // 省略可能、デフォルト null(無制限)
52
+ killPointMultiplier: 1 // 1撃破あたりの倍率(1の場合1撃破1pt, 2の場合1撃破2ポイント)、省略可能、デフォルト 1
53
+ });
44
54
 
45
- // マージ後の結果もsortScoresで再ソート可能
46
- const finalSorted = sortScores(mergedScores);
55
+ console.log('Score:', score);
47
56
 
48
- console.log('Merged and Sorted:', finalSorted);
57
+ // 複数マッチのマージと再ソート
58
+ const merged = mergeScores([ sortedScores, sortedScores2 ]);
59
+ const finalSorted = sortScores(merged);
49
60
 
50
- } catch (e) {
51
- console.error(e);
52
- }
61
+ console.log('Merged & Sorted:', finalSorted);
53
62
  })();
54
63
  ```
55
64
 
56
- ## calculateScoreの使い方
57
-
58
- リプレイ解析済みの`ReplayAnalysis` の `result.processedPlayerInfo` を保存した JSON ファイル(ファイル名は任意でOK)から、大会形式のスコアを計算したいときに使える。
59
-
60
- ```js
61
- const { calculateScore } = require('fortnite-replay-analysis');
62
-
63
- const score = await calculateScore({
64
- matchDataPath: './output/matchA1/playerInfo.json',
65
- points: {
66
- 1: 11, 2: 6, 3: 5, 4: 4, 5: 3,
67
- 6: 2, 7: 1, 8: 1, 9: 1, 10: 1
68
- },
69
- killPointMultiplier: 1,
70
- killCountUpperLimit: 10
71
- });
72
-
73
- console.log(score);
74
- ```
75
-
76
65
  ## API
77
66
 
78
- ### `ReplayAnalysis(replayFileDir, options)`
67
+ ### `ReplayAnalysis(inputPath, options)`
79
68
 
80
- * `replayFileDir`:リプレイファイルが入ったディレクトリのパス
81
- * `options`:
69
+ * `inputPath`: .replayファイルがあるディレクトリまたはファイルのパス
70
+ * `options`(省略可):
82
71
 
83
- * `bot`(boolean):botプレイヤーを結果に含めるか(デフォルトfalse
84
- * `sort`(boolean):順位でソートするか(デフォルトtrue
85
- * 返り値はPromiseで、`rawPlayerData`と`processedPlayerInfo`を含むオブジェクトを返す
72
+ * `bot`(boolean): botプレイヤーを含めるか(デフォルト: `false`)
73
+ * `sort`(boolean): 順位でソートするか(デフォルト: `true`)
74
+ * 戻り値: Promise<{
75
+ rawReplayData: Object,
76
+ rawPlayerData: Array,
77
+ processedPlayerInfo: Array
78
+ }>
86
79
 
87
- ### `mergeScores(scoreArrays)`
80
+ ### `calculateScore({ matchData, points, killCountUpperLimit, killPointMultiplier })`
88
81
 
89
- * 複数マッチのスコア配列をパーティ単位でマージする
90
- * 返り値はマージされたスコア配列
82
+ * `matchData`: `ReplayAnalysis`の`processedPlayerInfo`配列、またはそのJSONファイルへのパス
83
+ * `points`: 順位ごとのポイント設定オブジェクト(例: `{1:11,2:6,...}`)
84
+ * `killCountUpperLimit`: キル数の上限(省略可能、デフォルト: `null` で無制限)
85
+ * `killPointMultiplier`: 1撃破あたりの倍率(1の場合1撃破1pt, 2の場合1撃破2ポイント)、省略可能、デフォルト: `1`
86
+ * 戻り値: Promise(パーティごとの集計結果)
91
87
 
92
88
  ### `sortScores(scoreArray)`
93
89
 
94
- * 公式準拠のルールでスコアをソートする
95
- * 引数はマージ済みのスコア配列
90
+ * 公式準拠のルールでスコアをソートして返します。
91
+ * 引数: `calculateScore`や`mergeScores`の戻り値として得られる配列
92
+ * ソート順:
96
93
 
97
- ### `calculateScore({ matchDataPath, points, killCountUpperLimit, killPointMultiplier })`
94
+ 1. 累計ポイント降順
95
+ 2. Victory Royale 回数降順
96
+ 3. 平均撃破数降順
97
+ 4. 平均順位昇順
98
+ 5. 合計生存時間降順
99
+ 6. 最初のパーティ番号昇順
98
100
 
99
- * `matchDataPath`:`ReplayAnalysis` の `result.processedPlayerInfo` を保存した JSON ファイルのパス(ファイル名は任意でOK)
100
- * `points`:順位に対するポイント設定(例:{ 1: 11, 2: 6, ... })
101
- * `killCountUpperLimit`:キル数制限(nullで無制限)
102
- * `killPointMultiplier`:キル数倍率(例:1なら1キル1ポイント、2なら1キル2ポイント)
103
-
104
- ## 動作環境
101
+ ### `mergeScores(scoreArrays)`
105
102
 
106
- * Node.js v22以上
107
- * Windows / Linux対応(Macは未対応)
108
- * C#で作られた自己完結バイナリが`CSproj/bin/Release/net8.0/`配下に同補されていること
103
+ * 複数マッチ分のスコア配列をパーティ単位でマージします。
104
+ * 引数: ソート済みスコア配列の配列(例: `[sorted1, sorted2, ...]`)
105
+ * 戻り値: マージ後のスコア配列
109
106
 
110
107
  ## 注意事項
111
108
 
112
- * リプレイファイルはディレクトリに1つ以上`.replay`ファイルが必要
113
- * ディレクトリ内に複数ファイルある場合は現状最初の1つのみ処理される
114
- * 何か問題起こっても俺は責任追わない
115
- * このリポジトリをフォークする際は、GitHubの「Fork」ボタンからフォークしてください。
116
- git cloneして新しく別リポジトリを作るのではなく、GitHub上のフォーク機能を使っていただけると、変更履歴を正しく追えます。
117
- ご協力よろしくお願いします!
109
+ * ディレクトリ指定時は最初に見つけた `.replay` を処理します。
110
+ * 直接ファイルを指定した場合はそのファイルを処理し、`.replay` が存在しない場合でも最初に見つけたものを使用します。
111
+ * 問題が発生しても責任は負いかねます。
112
+ * フォークする場合はGitHubFork機能を利用し、履歴を追いやすくしてください。
package/index.js CHANGED
@@ -17,24 +17,27 @@ function getBinaryPath() { // OS判定して自己完結バイナリの実行フ
17
17
  }
18
18
  }
19
19
 
20
- function ReplayAnalysis(replayFileDir, { bot = false, sort = true } = {}) { // Fortniteのリプレイファイルを解析してプレイヤーデータを返す
20
+ function ReplayAnalysis(inputPath, { bot = false, sort = true } = {}) { // Fortniteのリプレイファイルを解析してプレイヤーデータを返す
21
21
  return new Promise((resolve, reject) => {
22
22
 
23
- let replayFiles;
23
+ let replayFilePath;
24
+
24
25
  try {
25
- replayFiles = fs.readdirSync(replayFileDir).filter(f => f.endsWith('.replay'));
26
+ const stat = fs.statSync(inputPath);
27
+ if (stat.isDirectory()) {
28
+ const replayFiles = fs.readdirSync(inputPath).filter(f => f.endsWith('.replay'));
29
+ if (replayFiles.length === 0) {
30
+ return reject(new Error(`No .replay files found in directory: ${inputPath}`));
31
+ }
32
+ replayFilePath = path.join(inputPath, replayFiles[0]);
33
+ } else if (stat.isFile()) {
34
+ replayFilePath = inputPath;
35
+ } else {
36
+ return reject(new Error(`Invalid input path: ${inputPath}`));
37
+ }
26
38
  } catch (e) {
27
- reject(new Error(`Failed to read directory: ${e.message}`));
28
- return;
39
+ return reject(new Error(`Failed to access path: ${e.message}`));
29
40
  }
30
-
31
- if (replayFiles.length === 0) {
32
- reject(new Error(`No replay file found in directory: ${replayFileDir}`));
33
- return;
34
- }
35
-
36
- // とりあえず1個目のファイルを処理(複数ある場合は要拡張)
37
- const replayFilePath = path.join(replayFileDir, replayFiles[0]);
38
41
  const binPath = getBinaryPath();
39
42
 
40
43
  execFile(binPath, [replayFilePath], (error, stdout, stderr) => {
@@ -46,8 +49,17 @@ function ReplayAnalysis(replayFileDir, { bot = false, sort = true } = {}) { // F
46
49
  console.warn(`Warning: ${stderr}`);
47
50
  }
48
51
 
52
+ let parsed;
53
+ try {
54
+ parsed = JSON.parse(stdout);
55
+ } catch (jsonErr) {
56
+ reject(new Error(`JSON parse error: ${jsonErr.message}`));
57
+ return;
58
+ }
59
+
60
+ const playerData = parsed.PlayerData;
61
+
49
62
  try {
50
- const playerData = JSON.parse(stdout);
51
63
 
52
64
  if (!Array.isArray(playerData)) {
53
65
  reject(new Error(`Unexpected JSON format: playerData is not an array.`));
@@ -94,7 +106,9 @@ function ReplayAnalysis(replayFileDir, { bot = false, sort = true } = {}) { // F
94
106
  }
95
107
 
96
108
  resolve({
97
- rawPlayerData: playerData, processedPlayerInfo: filteredAndSortedPlayerInfo,
109
+ rawReplayData: parsed,
110
+ rawPlayerData: playerData,
111
+ processedPlayerInfo: filteredAndSortedPlayerInfo
98
112
  });
99
113
  } catch (jsonErr) {
100
114
  reject(new Error(`JSON parse error: ${jsonErr.message}`));
@@ -103,18 +117,24 @@ function ReplayAnalysis(replayFileDir, { bot = false, sort = true } = {}) { // F
103
117
  });
104
118
  }
105
119
 
106
- async function calculateScore({ matchDataPath, points, killCountUpperLimit = null, killPointMultiplier = 1 } = {}) {
107
- if (!matchDataPath || !fs.existsSync(matchDataPath)) {
108
- throw new Error(`Match data file not found: ${matchDataPath}`);
109
- }
110
- if (!points || typeof points !== 'object' || Object.keys(points).length === 0) {
111
- throw new Error('Points configuration is required and must be a non-empty object.');
120
+ async function calculateScore({ matchData, points, killCountUpperLimit = null, killPointMultiplier = 1 } = {}) {
121
+ if (!matchData) throw new Error('matchData is required');
122
+
123
+ let playerInfo;
124
+ if (typeof matchData === 'string') {
125
+ if (!fs.existsSync(matchData)) throw new Error(`Match data path does not exist: ${matchData}`);
126
+ const rawData = fs.readFileSync(matchData, 'utf8');
127
+ try {
128
+ playerInfo = JSON.parse(rawData);
129
+ } catch (e) {
130
+ throw new Error(`Failed to parse JSON from file: ${e.message}`);
131
+ }
112
132
  }
113
- if (killCountUpperLimit !== null && (typeof killCountUpperLimit !== 'number' || killCountUpperLimit < 0)) {
114
- throw new Error('killCountUpperLimit must be a non-negative number or null.');
133
+ else if (Array.isArray(matchData)) playerInfo = matchData;
134
+ else {
135
+ throw new Error('matchData must be either a file path (string) or parsed JSON array');
115
136
  }
116
137
 
117
- const playerInfo = JSON.parse(fs.readFileSync(path.join(matchDataPath), 'utf8'));
118
138
  const partyScore = playerInfo.reduce((acc, player) => {
119
139
  if (!acc[player.partyNumber]) {
120
140
  const limitedKills = killCountUpperLimit == null
@@ -157,39 +177,38 @@ function mergeScores(scoreArrays) { // 複数マッチの結果をマージし
157
177
  const key = JSON.stringify([...p.partyMemberIdList].sort());
158
178
  if (!map.has(key)) {
159
179
  map.set(key, {
160
- partyPlacement: null,
161
- partyNumber: p.partyNumber,
162
180
  partyScore: p.partyScore,
163
181
  partyPoint: p.partyPoint,
164
182
  partyKills: p.partyKills,
165
- partyMemberList: [...p.partyMemberList],
166
- matchList: [p.matchName],
183
+ partyKillsNoLimit: p.partyKillsNoLimit,
167
184
  partyVictoryRoyaleCount: p.partyVictoryRoyale ? 1 : 0,
185
+ matchList: [p.matchName],
186
+ partyMemberList: [...p.partyMemberList],
168
187
  partyAliveTimeByMatch: [
169
188
  { match: p.matchName, times: [...(p.partyAliveTimeList || [])] }
170
189
  ],
171
- partyPlacementList: [p.partyPlacement]
190
+ partyPlacementList: [p.partyPlacement],
191
+ matchs: { [p.matchName]: { ...p } }
172
192
  });
173
193
  } else {
174
194
  const ex = map.get(key);
175
- ex.matchList.push(p.matchName);
176
195
  ex.partyScore += p.partyScore;
177
- ex.partyKills += p.partyKills;
178
196
  ex.partyPoint += p.partyPoint;
197
+ ex.partyKills += p.partyKills;
198
+ ex.partyKillsNoLimit += p.partyKillsNoLimit;
179
199
  ex.partyVictoryRoyaleCount += p.partyVictoryRoyale ? 1 : 0;
200
+ ex.matchList.push(p.matchName);
180
201
  ex.partyAliveTimeByMatch.push({
181
202
  match: p.matchName,
182
203
  times: [...(p.partyAliveTimeList || [])]
183
204
  });
184
205
  ex.partyPlacementList.push(p.partyPlacement);
206
+ ex.matchs[p.matchName] = { ...p };
185
207
  }
186
208
  })
187
209
  );
188
210
 
189
- return Array.from(map.values()).map(p => ({
190
- ...p,
191
- partyPlacement: p.partyPlacementList.reduce((a, b) => a + b, 0) / p.partyPlacementList.length
192
- }));
211
+ return Array.from(map.values());
193
212
  }
194
213
 
195
214
  function sortScores(arr) { // 公式準拠のスコアソート関数
@@ -197,47 +216,58 @@ function sortScores(arr) { // 公式準拠のスコアソート関数
197
216
  if (!Array.isArray(arr) || arr.length === 0) return arr;
198
217
 
199
218
  arr.forEach(p => {
200
- const matchCount = (p.partyPlacementList || []).filter((placement, i, arr) =>
201
- placement === 1 && p.partyNumber === (arr[i] || {}).partyNumber
202
- ).length || 1;
203
- p.result = {
219
+ const matchCount = (p.matchList || []).length || 1;
220
+ p.summary = {
204
221
  point: p.partyScore || 0,
205
222
  victoryCount: p.partyVictoryRoyaleCount ?? (p.partyVictoryRoyale ? 1 : 0),
206
223
  matchCount,
207
- avgKills: (p.partyKills || 0) / matchCount,
208
- avgPlacement: (p.partyPlacementList && p.partyPlacementList.length > 0)
209
- ? (p.partyPlacementList.reduce((s, x) => s + x, 0) / p.partyPlacementList.length)
210
- : p.partyPlacement,
224
+ // Decimalを使って計算
225
+ avgKills: matchCount > 0
226
+ ? new Decimal(p.partyKills || 0).dividedBy(matchCount)
227
+ : new Decimal(p.partyKills || 0),
228
+ avgPlacement: Array.isArray(p.partyPlacementList) && p.partyPlacementList.length > 0 && matchCount > 0
229
+ ? new Decimal(p.partyPlacementList.reduce((sum, val) => sum + val, 0)).dividedBy(matchCount)
230
+ : new Decimal(p.partyPlacement || 0),
211
231
  totalAliveTime: sumMaxAliveTime(p.partyAliveTimeList, p.partyAliveTimeByMatch),
212
232
  };
213
233
  });
214
234
 
215
235
  return arr.sort((a, b) => {
216
236
  // 1. 累計獲得ポイント
217
- if (b.result.point !== a.result.point) {
218
- return b.result.point - a.result.point;
237
+ if (b.summary.point !== a.summary.point) {
238
+ return b.summary.point - a.summary.point;
219
239
  }
220
240
  // 2. セッション中の累計 Victory Royale 回数
221
- if (b.result.victoryCount !== a.result.victoryCount) {
222
- return b.result.victoryCount - a.result.victoryCount;
241
+ if (b.summary.victoryCount !== a.summary.victoryCount) {
242
+ return b.summary.victoryCount - a.summary.victoryCount;
223
243
  }
224
244
 
225
245
  // 3. 平均撃破数
226
- if (b.result.avgKills !== a.result.avgKills) {
227
- return b.result.avgKills - a.result.avgKills;
246
+ const cmpAvgKills = b.summary.avgKills.comparedTo(a.summary.avgKills);
247
+ if (cmpAvgKills !== 0) {
248
+ return cmpAvgKills;
228
249
  }
229
250
 
230
251
  // 4. 平均順位(小さいほうが上位)
231
- if (b.result.avgPlacement !== a.result.avgPlacement) {
232
- return b.result.avgPlacement - a.result.avgPlacement;
252
+ const cmpAvgPlacement = b.summary.avgPlacement.comparedTo(a.summary.avgPlacement);
253
+ if (cmpAvgPlacement !== 0) {
254
+ return cmpAvgPlacement;
233
255
  }
234
256
 
235
257
  // 5. 全マッチの合計生存時間
236
- const cmp = b.result.totalAliveTime.comparedTo(a.result.totalAliveTime);
237
- if (cmp !== 0) return cmp;
258
+ const cmpTime = b.summary.totalAliveTime.comparedTo(a.summary.totalAliveTime);
259
+ if (cmpTime !== 0) {
260
+ return cmpTime;
261
+ }
238
262
 
239
263
  // 6. 最終手段:1マッチ目のパーティ番号が小さい順
240
- return a.partyNumber - b.partyNumber;
264
+ const numA = Array.isArray(a.matchList) && a.matchList.length > 0
265
+ ? a.matchs[a.matchList[0]].partyNumber
266
+ : a.partyNumber;
267
+ const numB = Array.isArray(b.matchList) && b.matchList.length > 0
268
+ ? b.matchs[b.matchList[0]].partyNumber
269
+ : b.partyNumber;
270
+ return numA - numB;
241
271
  });
242
272
  }
243
273
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fortnite-replay-analysis",
3
- "version": "1.0.8",
3
+ "version": "1.1.0",
4
4
  "description": "Fortniteのリプレイ解析をNode.jsから呼べる自己完結型C#バイナリラッパー",
5
5
  "repository": {
6
6
  "type": "git",