Add ConnectionFailureAnalyser

First custom Analyser subclass in this game tree. PatternAnalyser
operates per-entry without cross-entry state, so pairing
'attempting to join' with 'allowed to join' per Steam ID requires a
bespoke pass over the log. The analyser counts attempts and allowed
events per Steam ID and emits a ConnectionFailureProblem for each
player whose attempt count exceeds their allowed count. Unmatched
'attempting to join used queue' rows are surfaced as failures in v1
because a long queue wait is indistinguishable from a real failure
without timing context.
This commit is contained in:
2026-04-30 22:39:13 +00:00
parent c444e8543b
commit 73e9ca6181
5 changed files with 176 additions and 2 deletions

View File

@@ -0,0 +1,64 @@
<?php
namespace IndifferentKetchup\Codex\Analyser\ProjectZomboid;
use IndifferentKetchup\Codex\Analyser\Analyser;
use IndifferentKetchup\Codex\Analysis\Analysis;
use IndifferentKetchup\Codex\Analysis\AnalysisInterface;
use IndifferentKetchup\Codex\Analysis\ProjectZomboid\ConnectionFailureProblem;
use IndifferentKetchup\Codex\Pattern\ProjectZomboid\UserPattern;
/**
* Pairs "attempting to join" with subsequent "allowed to join" events per
* Steam ID and flags any unmatched attempts. PatternAnalyser cannot express
* this because it operates per-entry without cross-entry state, so this
* walks the entire log once and aggregates before emitting Problems.
*
* "attempting to join used queue" is treated as an attempt; a player still
* waiting in queue at end-of-log will therefore be flagged. This is
* intentional v1 behaviour — a long-lived queue wait looks indistinguishable
* from a real failure without timing context, and surfacing both lets a
* human triage.
*/
class ConnectionFailureAnalyser extends Analyser
{
public function analyse(): AnalysisInterface
{
$analysis = new Analysis();
$analysis->setLog($this->log);
$attempts = [];
$allowed = [];
$playerName = [];
foreach ($this->log as $entry) {
$text = (string) $entry;
if (preg_match(UserPattern::PLAYER_EVENT, $text, $m) !== 1) {
continue;
}
$steamId = $m['steamid'];
$playerName[$steamId] = $m['player'];
if (str_starts_with($m['event'], 'attempting to join')) {
$attempts[$steamId] = ($attempts[$steamId] ?? 0) + 1;
} elseif (str_starts_with($m['event'], 'allowed to join')) {
$allowed[$steamId] = ($allowed[$steamId] ?? 0) + 1;
}
}
foreach ($attempts as $steamId => $attemptCount) {
$allowedCount = $allowed[$steamId] ?? 0;
$unmatched = $attemptCount - $allowedCount;
if ($unmatched <= 0) {
continue;
}
$analysis->addInsight((new ConnectionFailureProblem())
->setSteamId($steamId)
->setPlayer($playerName[$steamId] ?? '')
->setUnmatchedAttempts($unmatched));
}
return $analysis;
}
}

View File

@@ -0,0 +1,67 @@
<?php
namespace IndifferentKetchup\Codex\Analysis\ProjectZomboid;
use IndifferentKetchup\Codex\Analysis\InsightInterface;
use IndifferentKetchup\Codex\Analysis\Problem;
/**
* Problem emitted by ConnectionFailureAnalyser when a player's
* "attempting to join" event count exceeds their "allowed to join" count
* within the same log file. Coalesced by Steam ID so each player produces
* at most one problem regardless of how many unmatched attempts they have.
*/
class ConnectionFailureProblem extends Problem
{
private string $steamId = '';
private string $player = '';
private int $unmatchedAttempts = 0;
public function setSteamId(string $steamId): static
{
$this->steamId = $steamId;
return $this;
}
public function setPlayer(string $player): static
{
$this->player = $player;
return $this;
}
public function setUnmatchedAttempts(int $count): static
{
$this->unmatchedAttempts = $count;
return $this;
}
public function getSteamId(): string
{
return $this->steamId;
}
public function getPlayer(): string
{
return $this->player;
}
public function getUnmatchedAttempts(): int
{
return $this->unmatchedAttempts;
}
public function getMessage(): string
{
return sprintf(
'Player %s (%s) had %d "attempting to join" event(s) without a matching "allowed to join".',
$this->player,
$this->steamId,
$this->unmatchedAttempts
);
}
public function isEqual(InsightInterface $insight): bool
{
return $insight instanceof self && $insight->getSteamId() === $this->steamId;
}
}

View File

@@ -3,7 +3,7 @@
namespace IndifferentKetchup\Codex\Log\ProjectZomboid; namespace IndifferentKetchup\Codex\Log\ProjectZomboid;
use IndifferentKetchup\Codex\Analyser\AnalyserInterface; use IndifferentKetchup\Codex\Analyser\AnalyserInterface;
use IndifferentKetchup\Codex\Analyser\PatternAnalyser; use IndifferentKetchup\Codex\Analyser\ProjectZomboid\ConnectionFailureAnalyser;
use IndifferentKetchup\Codex\Detective\FilenameDetector; use IndifferentKetchup\Codex\Detective\FilenameDetector;
use IndifferentKetchup\Codex\Detective\WeightedSinglePatternDetector; use IndifferentKetchup\Codex\Detective\WeightedSinglePatternDetector;
use IndifferentKetchup\Codex\Parser\ParserInterface; use IndifferentKetchup\Codex\Parser\ParserInterface;
@@ -22,7 +22,7 @@ class ProjectZomboidUserLog extends ProjectZomboidEventLog
public static function getDefaultAnalyser(): AnalyserInterface public static function getDefaultAnalyser(): AnalyserInterface
{ {
return new PatternAnalyser(); return new ConnectionFailureAnalyser();
} }
public static function getDetectors(): array public static function getDetectors(): array

View File

@@ -0,0 +1,43 @@
<?php
namespace IndifferentKetchup\Codex\Test\Tests\Games\ProjectZomboid\Analyser;
use IndifferentKetchup\Codex\Analysis\ProjectZomboid\ConnectionFailureProblem;
use IndifferentKetchup\Codex\Log\File\PathLogFile;
use IndifferentKetchup\Codex\Log\ProjectZomboid\ProjectZomboidUserLog;
use PHPUnit\Framework\TestCase;
class UserLogAnalysisTest extends TestCase
{
private function fixturePath(): string
{
return __DIR__ . '/../../../../src/Games/ProjectZomboid/fixtures/user-minimal.txt';
}
public function testFlagsPlayerWithUnmatchedAttempts(): void
{
$log = (new ProjectZomboidUserLog())->setLogFile(new PathLogFile($this->fixturePath()));
$log->parse();
$analysis = $log->analyse();
$problems = $analysis->getFilteredInsights(ConnectionFailureProblem::class);
$this->assertCount(1, $problems);
$problem = $problems[0];
$this->assertSame('76561198000000001', $problem->getSteamId());
$this->assertSame('Player1', $problem->getPlayer());
$this->assertSame(1, $problem->getUnmatchedAttempts());
}
public function testDoesNotFlagPlayerWithMatchedAttempts(): void
{
$log = (new ProjectZomboidUserLog())->setLogFile(new PathLogFile($this->fixturePath()));
$log->parse();
$analysis = $log->analyse();
$problems = $analysis->getFilteredInsights(ConnectionFailureProblem::class);
foreach ($problems as $problem) {
$this->assertNotSame('76561198000000002', $problem->getSteamId());
}
}
}