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.
This commit is contained in:
2026-04-30 22:43:44 +00:00
parent ba3fae8736
commit 0c90e40a28
6 changed files with 267 additions and 3 deletions

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,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; 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\SkillProgressionAnomalyAnalyser;
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 ProjectZomboidPerkLog extends ProjectZomboidEventLog
public static function getDefaultAnalyser(): AnalyserInterface public static function getDefaultAnalyser(): AnalyserInterface
{ {
return new PatternAnalyser(); return new SkillProgressionAnomalyAnalyser();
} }
public static function getDetectors(): array public static function getDetectors(): array

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

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