diff --git a/src/Analyser/ProjectZomboid/SkillProgressionAnomalyAnalyser.php b/src/Analyser/ProjectZomboid/SkillProgressionAnomalyAnalyser.php new file mode 100644 index 0000000..5cff200 --- /dev/null +++ b/src/Analyser/ProjectZomboid/SkillProgressionAnomalyAnalyser.php @@ -0,0 +1,87 @@ +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; + } +} diff --git a/src/Analysis/ProjectZomboid/SkillProgressionAnomalyProblem.php b/src/Analysis/ProjectZomboid/SkillProgressionAnomalyProblem.php new file mode 100644 index 0000000..a94d6fa --- /dev/null +++ b/src/Analysis/ProjectZomboid/SkillProgressionAnomalyProblem.php @@ -0,0 +1,107 @@ +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; + } +} diff --git a/src/Log/ProjectZomboid/ProjectZomboidPerkLog.php b/src/Log/ProjectZomboid/ProjectZomboidPerkLog.php index 54d19bd..3396a98 100644 --- a/src/Log/ProjectZomboid/ProjectZomboidPerkLog.php +++ b/src/Log/ProjectZomboid/ProjectZomboidPerkLog.php @@ -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 diff --git a/test/src/Games/ProjectZomboid/fixtures/perk-minimal.txt b/test/src/Games/ProjectZomboid/fixtures/perk-minimal.txt index 5b24042..b01e034 100644 --- a/test/src/Games/ProjectZomboid/fixtures/perk-minimal.txt +++ b/test/src/Games/ProjectZomboid/fixtures/perk-minimal.txt @@ -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]. diff --git a/test/tests/Games/ProjectZomboid/Analyser/PerkLogAnalysisTest.php b/test/tests/Games/ProjectZomboid/Analyser/PerkLogAnalysisTest.php new file mode 100644 index 0000000..f7c42e7 --- /dev/null +++ b/test/tests/Games/ProjectZomboid/Analyser/PerkLogAnalysisTest.php @@ -0,0 +1,66 @@ +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); + } +} diff --git a/test/tests/Games/ProjectZomboid/Log/ProjectZomboidPerkLogTest.php b/test/tests/Games/ProjectZomboid/Log/ProjectZomboidPerkLogTest.php index 4db676c..586252b 100644 --- a/test/tests/Games/ProjectZomboid/Log/ProjectZomboidPerkLogTest.php +++ b/test/tests/Games/ProjectZomboid/Log/ProjectZomboidPerkLogTest.php @@ -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