Compare commits

4 Commits

Author SHA1 Message Date
0c90e40a28 Add SkillProgressionAnomalyAnalyser
Some checks failed
Tests / Run tests on PHP v8.4 (push) Failing after 1s
Tests / Run tests on PHP v8.5 (push) Failing after 0s
Compares consecutive perks-snapshot rows per Steam ID and emits a
SkillProgressionAnomalyProblem for any single skill whose level gained
more than THRESHOLD_DELTA between two snapshots. Login/Logout/LevelUp
event rows are skipped via a perk-pair regex check on the bracketed
event field.

Threshold of 3 reflects PZ's slow leveling pace: typical session bridges
should not produce four-or-more level jumps in a single skill. The
constant is documented inline so operators can tune for modded XP
servers without touching analysis logic.

Synthetic fixture extended with a PlayerSuspect Steam ID carrying two
snapshots: Strength jumps 2 -> 10 (delta +8, triggers), Fitness jumps
2 -> 8 (+6, triggers), Maintenance jumps 0 -> 3 (+3, exactly at
threshold, does NOT trigger). The existing single-snapshot players
remain noise-free.
2026-04-30 22:43:44 +00:00
ba3fae8736 Add ItemDuplicationAnalyser
Sliding-window heuristic over (Steam ID, item code) groups: any window of
THRESHOLD_WINDOW_SECONDS containing THRESHOLD_COUNT or more positive-delta
events for the same player/item pair triggers a Problem. Negative deltas
(drops, transfers out) are filtered. Five events in ten seconds (defaults)
encodes the rule of thumb that legitimate gameplay rarely produces five
identical items in that span.

Constants live as class constants on the analyser so operators can
override via subclass without touching analysis logic; the docblocks
record the justification.

Synthetic fixture extended with a 6-event burst (AdminUser +
Base.Bullets9mm in <1s) and a 4-event sub-threshold group (Player1 +
Base.Plank scattered over 4 minutes) to exercise both paths.
2026-04-30 22:41:36 +00:00
73e9ca6181 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.
2026-04-30 22:39:13 +00:00
c444e8543b pre-phase-B.3 checkpoint 2026-04-30 22:38:00 +00:00
17 changed files with 679 additions and 8 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,90 @@
<?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\ItemDuplicationProblem;
use IndifferentKetchup\Codex\Pattern\ProjectZomboid\ItemPattern;
/**
* Flags suspicious item-gain frequency per (player, item) tuple. Slides a
* fixed-second window across each group's events; a window with at least
* THRESHOLD_COUNT positive-delta events triggers a problem.
*
* Negative-delta events (drops, transfers out) are ignored — they do not
* indicate creation of items and a sufficiently fast trade-and-pickup loop
* would self-cancel.
*
* Entry::getTime() resolves to integer Unix seconds, so sub-second
* timestamps in the fixture all collapse to the same value. This is
* acceptable for v1: events emitted within the same second are by
* definition within any positive window.
*/
class ItemDuplicationAnalyser extends Analyser
{
/**
* Minimum number of same-item gain events that must fall inside the
* window before a Problem is emitted. Five was picked because legitimate
* gameplay rarely produces five identical items in ten seconds:
* crafting has animation delays, looting is one-at-a-time, and zombie
* drops are similarly serial. A burst of five suggests admin-spawn or
* exploit. Tune downward if false negatives appear in production logs.
*/
public const int THRESHOLD_COUNT = 5;
/**
* Length of the sliding window in seconds. Ten seconds covers a
* realistic burst-loot scenario (e.g. crate of identical items) without
* collapsing onto unrelated events. Combined with THRESHOLD_COUNT this
* means an effective rate of 0.5 same-item events per second.
*/
public const int THRESHOLD_WINDOW_SECONDS = 10;
public function analyse(): AnalysisInterface
{
$analysis = new Analysis();
$analysis->setLog($this->log);
$groups = [];
foreach ($this->log as $entry) {
if (preg_match(ItemPattern::FIELDS, (string) $entry, $m) !== 1) {
continue;
}
if (!str_starts_with($m['delta'], '+')) {
continue;
}
$key = $m['steamid'] . '|' . $m['item'];
$groups[$key][] = [
'time' => $entry->getTime() ?? 0,
'steamid' => $m['steamid'],
'item' => $m['item'],
'player' => $m['player'],
];
}
foreach ($groups as $events) {
usort($events, static fn($a, $b) => $a['time'] <=> $b['time']);
$left = 0;
$eventCount = count($events);
for ($right = 0; $right < $eventCount; $right++) {
while ($events[$right]['time'] - $events[$left]['time'] > self::THRESHOLD_WINDOW_SECONDS) {
$left++;
}
if (($right - $left + 1) >= self::THRESHOLD_COUNT) {
$sample = $events[0];
$analysis->addInsight((new ItemDuplicationProblem())
->setSteamId($sample['steamid'])
->setPlayer($sample['player'])
->setItem($sample['item'])
->setEventCount($eventCount));
break;
}
}
}
return $analysis;
}
}

View File

@@ -0,0 +1,87 @@
<?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\SkillProgressionAnomalyProblem;
use IndifferentKetchup\Codex\Pattern\ProjectZomboid\PerkPattern;
/**
* Walks PerkLog entries, parses each perks-snapshot row into a
* skill->level dict, and compares consecutive snapshots per Steam ID. If
* any single skill gained more than THRESHOLD_DELTA levels between
* snapshots, emits a SkillProgressionAnomalyProblem for that
* (player, skill) pair.
*
* Login/Logout/LevelUp event rows are skipped — they have a single token
* in the event field rather than a comma-separated list of Skill=N pairs.
*/
class SkillProgressionAnomalyAnalyser extends Analyser
{
/**
* Maximum plausible single-skill gain between two consecutive snapshots
* of the same player. Project Zomboid skill leveling is slow: most
* skills require thousands of XP per level, and even maxed grinding
* setups don't routinely produce four-or-more level jumps in a single
* session bridge. Set to 3 as a baseline; if production logs surface
* frequent legitimate jumps of 4 (e.g. on heavily modded XP servers),
* raise via subclass override or tune downward to catch finer abuse.
*/
public const int THRESHOLD_DELTA = 3;
public function analyse(): AnalysisInterface
{
$analysis = new Analysis();
$analysis->setLog($this->log);
$snapshots = [];
foreach ($this->log as $entry) {
$text = (string) $entry;
if (preg_match(PerkPattern::FIELDS, $text, $m) !== 1) {
continue;
}
if (preg_match(PerkPattern::PERK_PAIR, $m['event']) !== 1) {
continue;
}
preg_match_all(PerkPattern::PERK_PAIR, $m['event'], $pairs, PREG_SET_ORDER);
$skills = [];
foreach ($pairs as $pair) {
$skills[$pair['skill']] = (int) $pair['level'];
}
$snapshots[$m['steamid']][] = [
'time' => $entry->getTime() ?? 0,
'player' => $m['player'],
'skills' => $skills,
];
}
foreach ($snapshots as $steamId => $playerSnapshots) {
usort($playerSnapshots, static fn($a, $b) => $a['time'] <=> $b['time']);
for ($i = 1; $i < count($playerSnapshots); $i++) {
$prev = $playerSnapshots[$i - 1];
$curr = $playerSnapshots[$i];
foreach ($curr['skills'] as $skill => $currLevel) {
$prevLevel = $prev['skills'][$skill] ?? 0;
$delta = $currLevel - $prevLevel;
if ($delta > self::THRESHOLD_DELTA) {
$analysis->addInsight((new SkillProgressionAnomalyProblem())
->setSteamId($steamId)
->setPlayer($curr['player'])
->setSkill($skill)
->setFromLevel($prevLevel)
->setToLevel($currLevel)
->setDelta($delta));
}
}
}
}
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

@@ -0,0 +1,82 @@
<?php
namespace IndifferentKetchup\Codex\Analysis\ProjectZomboid;
use IndifferentKetchup\Codex\Analysis\InsightInterface;
use IndifferentKetchup\Codex\Analysis\Problem;
/**
* Problem emitted by ItemDuplicationAnalyser when a player gains the same
* item code at a rate that exceeds the configured threshold. Coalesced by
* the (Steam ID, item code) tuple so each suspicious group produces one
* problem regardless of how many events fall inside the window.
*/
class ItemDuplicationProblem extends Problem
{
private string $steamId = '';
private string $player = '';
private string $item = '';
private int $eventCount = 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 setItem(string $item): static
{
$this->item = $item;
return $this;
}
public function setEventCount(int $count): static
{
$this->eventCount = $count;
return $this;
}
public function getSteamId(): string
{
return $this->steamId;
}
public function getPlayer(): string
{
return $this->player;
}
public function getItem(): string
{
return $this->item;
}
public function getEventCount(): int
{
return $this->eventCount;
}
public function getMessage(): string
{
return sprintf(
'Player %s (%s) gained %s %d times at a rate above the duplication threshold.',
$this->player,
$this->steamId,
$this->item,
$this->eventCount
);
}
public function isEqual(InsightInterface $insight): bool
{
return $insight instanceof self
&& $insight->getSteamId() === $this->steamId
&& $insight->getItem() === $this->item;
}
}

View File

@@ -0,0 +1,107 @@
<?php
namespace IndifferentKetchup\Codex\Analysis\ProjectZomboid;
use IndifferentKetchup\Codex\Analysis\InsightInterface;
use IndifferentKetchup\Codex\Analysis\Problem;
/**
* Problem emitted by SkillProgressionAnomalyAnalyser when a single skill
* gained more than the configured threshold between two consecutive
* snapshots of the same player. Coalesced by (Steam ID, skill).
*/
class SkillProgressionAnomalyProblem extends Problem
{
private string $steamId = '';
private string $player = '';
private string $skill = '';
private int $fromLevel = 0;
private int $toLevel = 0;
private int $delta = 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 setSkill(string $skill): static
{
$this->skill = $skill;
return $this;
}
public function setFromLevel(int $level): static
{
$this->fromLevel = $level;
return $this;
}
public function setToLevel(int $level): static
{
$this->toLevel = $level;
return $this;
}
public function setDelta(int $delta): static
{
$this->delta = $delta;
return $this;
}
public function getSteamId(): string
{
return $this->steamId;
}
public function getPlayer(): string
{
return $this->player;
}
public function getSkill(): string
{
return $this->skill;
}
public function getFromLevel(): int
{
return $this->fromLevel;
}
public function getToLevel(): int
{
return $this->toLevel;
}
public function getDelta(): int
{
return $this->delta;
}
public function getMessage(): string
{
return sprintf(
'Player %s (%s) gained %d levels of %s between snapshots (%d to %d).',
$this->player,
$this->steamId,
$this->delta,
$this->skill,
$this->fromLevel,
$this->toLevel
);
}
public function isEqual(InsightInterface $insight): bool
{
return $insight instanceof self
&& $insight->getSteamId() === $this->steamId
&& $insight->getSkill() === $this->skill;
}
}

View File

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

View File

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

View File

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

View File

@@ -8,3 +8,13 @@
[16-04-26 19:35:42.812] 76561198000000001 "Player1" container -1 1002,2002,0 [Base.WaterBottleFull].
[16-04-26 19:40:00.514] 76561198000000002 "Player2" floor +1 1011,2011,0 [Base.Bandage].
[16-04-26 19:42:25.223] 76561198000000002 "Player2" inventory +5 1011,2011,0 [Base.Bullets9mm].
[16-04-26 19:50:00.001] 76561198000000003 "AdminUser" inventory +1 1020,2020,0 [Base.Bullets9mm].
[16-04-26 19:50:00.002] 76561198000000003 "AdminUser" inventory +1 1020,2020,0 [Base.Bullets9mm].
[16-04-26 19:50:00.003] 76561198000000003 "AdminUser" inventory +1 1020,2020,0 [Base.Bullets9mm].
[16-04-26 19:50:00.004] 76561198000000003 "AdminUser" inventory +1 1020,2020,0 [Base.Bullets9mm].
[16-04-26 19:50:00.005] 76561198000000003 "AdminUser" inventory +1 1020,2020,0 [Base.Bullets9mm].
[16-04-26 19:50:00.006] 76561198000000003 "AdminUser" inventory +1 1020,2020,0 [Base.Bullets9mm].
[16-04-26 20:00:00.000] 76561198000000001 "Player1" floor +1 1004,2004,0 [Base.Plank].
[16-04-26 20:01:00.000] 76561198000000001 "Player1" floor +1 1004,2004,0 [Base.Plank].
[16-04-26 20:02:00.000] 76561198000000001 "Player1" floor +1 1004,2004,0 [Base.Plank].
[16-04-26 20:03:00.000] 76561198000000001 "Player1" floor +1 1004,2004,0 [Base.Plank].

View File

@@ -4,3 +4,7 @@
[16-04-26 18:29:15.823] [76561198000000002][Player2][1010,2010,0][Cooking=2, Fitness=10, Strength=10, Blunt=10, Axe=0, Lightfoot=4, Nimble=4, Sprinting=7, Sneak=2, Woodwork=6, Aiming=5, Reloading=4, Farming=0, Fishing=0, Trapping=0, PlantScavenging=0, Doctor=2, Electricity=5, Blacksmith=8, MetalWelding=10, Mechanics=10, Spear=0, Maintenance=7, SmallBlade=0, LongBlade=0, SmallBlunt=0, Tailoring=0, Tracking=0, Husbandry=0, FlintKnapping=0, Masonry=0, Pottery=0, Carving=0, Butchering=0, Glassmaking=0, Side_L=0, Side_R=0, ProstFamiliarity=0][Hours Survived: 50].
[16-04-26 18:30:02.500] [76561198000000003][AdminUser][1020,2020,0][Logout][Hours Survived: 75].
[16-04-26 19:15:00.000] [76561198000000001][Player1][1003,2003,1][LevelUp][Hours Survived: 101].
[16-04-26 18:30:00.000] [76561198000000004][PlayerSuspect][1030,2030,0][Login][Hours Survived: 10].
[16-04-26 18:30:00.000] [76561198000000004][PlayerSuspect][1030,2030,0][Cooking=2, Fitness=2, Strength=2, Blunt=0, Axe=0, Lightfoot=0, Nimble=0, Sprinting=0, Sneak=0, Woodwork=0, Aiming=0, Reloading=0, Farming=0, Fishing=0, Trapping=0, PlantScavenging=0, Doctor=0, Electricity=0, Blacksmith=0, MetalWelding=0, Mechanics=0, Spear=0, Maintenance=0, SmallBlade=0, LongBlade=0, SmallBlunt=0, Tailoring=0, Tracking=0, Husbandry=0, FlintKnapping=0, Masonry=0, Pottery=0, Carving=0, Butchering=0, Glassmaking=0, Side_L=0, Side_R=0, ProstFamiliarity=0][Hours Survived: 10].
[16-04-26 22:00:00.000] [76561198000000004][PlayerSuspect][1031,2031,0][Login][Hours Survived: 12].
[16-04-26 22:00:00.000] [76561198000000004][PlayerSuspect][1031,2031,0][Cooking=2, Fitness=8, Strength=10, Blunt=0, Axe=0, Lightfoot=0, Nimble=0, Sprinting=0, Sneak=0, Woodwork=0, Aiming=0, Reloading=0, Farming=0, Fishing=0, Trapping=0, PlantScavenging=0, Doctor=0, Electricity=0, Blacksmith=0, MetalWelding=0, Mechanics=0, Spear=0, Maintenance=3, SmallBlade=0, LongBlade=0, SmallBlunt=0, Tailoring=0, Tracking=0, Husbandry=0, FlintKnapping=0, Masonry=0, Pottery=0, Carving=0, Butchering=0, Glassmaking=0, Side_L=0, Side_R=0, ProstFamiliarity=0][Hours Survived: 12].

View File

@@ -0,0 +1,51 @@
<?php
namespace IndifferentKetchup\Codex\Test\Tests\Games\ProjectZomboid\Analyser;
use IndifferentKetchup\Codex\Analyser\ProjectZomboid\ItemDuplicationAnalyser;
use IndifferentKetchup\Codex\Analysis\ProjectZomboid\ItemDuplicationProblem;
use IndifferentKetchup\Codex\Log\File\PathLogFile;
use IndifferentKetchup\Codex\Log\ProjectZomboid\ProjectZomboidItemLog;
use PHPUnit\Framework\TestCase;
class ItemLogAnalysisTest extends TestCase
{
private function fixturePath(): string
{
return __DIR__ . '/../../../../src/Games/ProjectZomboid/fixtures/item-minimal.txt';
}
public function testFlagsBurstOfSameItemAboveThreshold(): void
{
$log = (new ProjectZomboidItemLog())->setLogFile(new PathLogFile($this->fixturePath()));
$log->parse();
$analysis = $log->analyse();
$problems = $analysis->getFilteredInsights(ItemDuplicationProblem::class);
$this->assertCount(1, $problems);
$problem = $problems[0];
$this->assertSame('76561198000000003', $problem->getSteamId());
$this->assertSame('AdminUser', $problem->getPlayer());
$this->assertSame('Base.Bullets9mm', $problem->getItem());
$this->assertSame(6, $problem->getEventCount());
}
public function testDoesNotFlagSubThresholdGroup(): void
{
$log = (new ProjectZomboidItemLog())->setLogFile(new PathLogFile($this->fixturePath()));
$log->parse();
$analysis = $log->analyse();
$problems = $analysis->getFilteredInsights(ItemDuplicationProblem::class);
foreach ($problems as $problem) {
$this->assertNotSame('Base.Plank', $problem->getItem());
}
}
public function testThresholdConstantsAreDocumentedAndPositive(): void
{
$this->assertGreaterThan(0, ItemDuplicationAnalyser::THRESHOLD_COUNT);
$this->assertGreaterThan(0, ItemDuplicationAnalyser::THRESHOLD_WINDOW_SECONDS);
}
}

View File

@@ -0,0 +1,66 @@
<?php
namespace IndifferentKetchup\Codex\Test\Tests\Games\ProjectZomboid\Analyser;
use IndifferentKetchup\Codex\Analyser\ProjectZomboid\SkillProgressionAnomalyAnalyser;
use IndifferentKetchup\Codex\Analysis\ProjectZomboid\SkillProgressionAnomalyProblem;
use IndifferentKetchup\Codex\Log\File\PathLogFile;
use IndifferentKetchup\Codex\Log\ProjectZomboid\ProjectZomboidPerkLog;
use PHPUnit\Framework\TestCase;
class PerkLogAnalysisTest extends TestCase
{
private function fixturePath(): string
{
return __DIR__ . '/../../../../src/Games/ProjectZomboid/fixtures/perk-minimal.txt';
}
public function testFlagsSkillsThatExceedDeltaThreshold(): void
{
$log = (new ProjectZomboidPerkLog())->setLogFile(new PathLogFile($this->fixturePath()));
$log->parse();
$analysis = $log->analyse();
$problems = $analysis->getFilteredInsights(SkillProgressionAnomalyProblem::class);
$skills = array_map(fn($p) => $p->getSkill(), $problems);
sort($skills);
$this->assertSame(['Fitness', 'Strength'], $skills);
foreach ($problems as $problem) {
$this->assertSame('76561198000000004', $problem->getSteamId());
$this->assertSame('PlayerSuspect', $problem->getPlayer());
}
}
public function testDeltaAtThresholdDoesNotTrigger(): void
{
$log = (new ProjectZomboidPerkLog())->setLogFile(new PathLogFile($this->fixturePath()));
$log->parse();
$analysis = $log->analyse();
$problems = $analysis->getFilteredInsights(SkillProgressionAnomalyProblem::class);
foreach ($problems as $problem) {
$this->assertNotSame('Maintenance', $problem->getSkill());
}
}
public function testSinglePlayerWithOneSnapshotProducesNoProblem(): void
{
$log = (new ProjectZomboidPerkLog())->setLogFile(new PathLogFile($this->fixturePath()));
$log->parse();
$analysis = $log->analyse();
$problems = $analysis->getFilteredInsights(SkillProgressionAnomalyProblem::class);
foreach ($problems as $problem) {
$this->assertNotSame('76561198000000001', $problem->getSteamId());
$this->assertNotSame('76561198000000002', $problem->getSteamId());
}
}
public function testThresholdConstantIsDocumentedAndPositive(): void
{
$this->assertGreaterThan(0, SkillProgressionAnomalyAnalyser::THRESHOLD_DELTA);
}
}

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());
}
}
}

View File

@@ -20,7 +20,7 @@ class ProjectZomboidItemLogTest extends TestCase
$log = (new ProjectZomboidItemLog())->setLogFile(new PathLogFile($this->fixturePath()));
$log->parse();
$this->assertCount(10, $log->getEntries());
$this->assertCount(20, $log->getEntries());
}
public function testFieldsRegexExtractsItemAndDelta(): void

View File

@@ -20,7 +20,7 @@ class ProjectZomboidPerkLogTest extends TestCase
$log = (new ProjectZomboidPerkLog())->setLogFile(new PathLogFile($this->fixturePath()));
$log->parse();
$this->assertCount(6, $log->getEntries());
$this->assertCount(10, $log->getEntries());
}
public function testFieldsRegexHandlesEventRow(): void