Add SkillProgressionAnomalyAnalyser
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:
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
107
src/Analysis/ProjectZomboid/SkillProgressionAnomalyProblem.php
Normal file
107
src/Analysis/ProjectZomboid/SkillProgressionAnomalyProblem.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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].
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user