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.
This commit is contained in:
2026-04-30 22:41:36 +00:00
parent 73e9ca6181
commit ba3fae8736
6 changed files with 236 additions and 3 deletions

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,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

@@ -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

@@ -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

@@ -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

@@ -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