From ba3fae8736535b1d457529a253d6f925add5f5cb Mon Sep 17 00:00:00 2001 From: indifferentketchup Date: Thu, 30 Apr 2026 22:41:36 +0000 Subject: [PATCH] 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. --- .../ItemDuplicationAnalyser.php | 90 +++++++++++++++++++ .../ProjectZomboid/ItemDuplicationProblem.php | 82 +++++++++++++++++ .../ProjectZomboid/ProjectZomboidItemLog.php | 4 +- .../ProjectZomboid/fixtures/item-minimal.txt | 10 +++ .../Analyser/ItemLogAnalysisTest.php | 51 +++++++++++ .../Log/ProjectZomboidItemLogTest.php | 2 +- 6 files changed, 236 insertions(+), 3 deletions(-) create mode 100644 src/Analyser/ProjectZomboid/ItemDuplicationAnalyser.php create mode 100644 src/Analysis/ProjectZomboid/ItemDuplicationProblem.php create mode 100644 test/tests/Games/ProjectZomboid/Analyser/ItemLogAnalysisTest.php diff --git a/src/Analyser/ProjectZomboid/ItemDuplicationAnalyser.php b/src/Analyser/ProjectZomboid/ItemDuplicationAnalyser.php new file mode 100644 index 0000000..f525703 --- /dev/null +++ b/src/Analyser/ProjectZomboid/ItemDuplicationAnalyser.php @@ -0,0 +1,90 @@ +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; + } +} diff --git a/src/Analysis/ProjectZomboid/ItemDuplicationProblem.php b/src/Analysis/ProjectZomboid/ItemDuplicationProblem.php new file mode 100644 index 0000000..7f5c414 --- /dev/null +++ b/src/Analysis/ProjectZomboid/ItemDuplicationProblem.php @@ -0,0 +1,82 @@ +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; + } +} diff --git a/src/Log/ProjectZomboid/ProjectZomboidItemLog.php b/src/Log/ProjectZomboid/ProjectZomboidItemLog.php index d7ebc9c..7a831b0 100644 --- a/src/Log/ProjectZomboid/ProjectZomboidItemLog.php +++ b/src/Log/ProjectZomboid/ProjectZomboidItemLog.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\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 diff --git a/test/src/Games/ProjectZomboid/fixtures/item-minimal.txt b/test/src/Games/ProjectZomboid/fixtures/item-minimal.txt index 96bbad7..e88ff6c 100644 --- a/test/src/Games/ProjectZomboid/fixtures/item-minimal.txt +++ b/test/src/Games/ProjectZomboid/fixtures/item-minimal.txt @@ -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]. diff --git a/test/tests/Games/ProjectZomboid/Analyser/ItemLogAnalysisTest.php b/test/tests/Games/ProjectZomboid/Analyser/ItemLogAnalysisTest.php new file mode 100644 index 0000000..95bd079 --- /dev/null +++ b/test/tests/Games/ProjectZomboid/Analyser/ItemLogAnalysisTest.php @@ -0,0 +1,51 @@ +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); + } +} diff --git a/test/tests/Games/ProjectZomboid/Log/ProjectZomboidItemLogTest.php b/test/tests/Games/ProjectZomboid/Log/ProjectZomboidItemLogTest.php index 79062b7..3a3cba2 100644 --- a/test/tests/Games/ProjectZomboid/Log/ProjectZomboidItemLogTest.php +++ b/test/tests/Games/ProjectZomboid/Log/ProjectZomboidItemLogTest.php @@ -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