From 7dac0ea5acfbd46479042ffbab5956a4ce7dffb5 Mon Sep 17 00:00:00 2001 From: Ihor Date: Sun, 7 Jun 2026 18:38:43 +0200 Subject: [PATCH] feat: enhance gen code with StatTrak values and detailed placement for stickers/keychains --- .gitignore | 1 + README.md | 37 ++++++++- src/GenCode.php | 182 +++++++++++++++++++++++++++++++++++++++++- tests/GenCodeTest.php | 175 +++++++++++++++++++++++++++++++++++----- 4 files changed, 368 insertions(+), 27 deletions(-) diff --git a/.gitignore b/.gitignore index 7032806..15678d6 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ vendor/ .phpunit.result.cache .DS_Store .idea/ +.vscode/ \ No newline at end of file diff --git a/README.md b/README.md index dd10611..3ed19af 100644 --- a/README.md +++ b/README.md @@ -83,11 +83,13 @@ Gen codes are space-separated command strings used on CS2 community servers to s Format: ``` -!gen {defindex} {paintindex} {paintseed} {paintwear} [{s0_id} {s0_wear} ... {s4_id} {s4_wear}] [{kc_id} {kc_wear} ...] +!gen {defindex} {paintindex} {paintseed} {paintwear} [{s0_id} {s0_wear} ... {s4_id} {s4_wear}] [{kc_id} {kc_wear} ...] {scoreType} {kills} [sd {n} ...] [kd {n} ...] ``` - Stickers are always represented as 5 slot pairs (padded with `0 0` for empty slots) - Keychains are appended without padding, only for present slots +- StatTrak is appended as a mandatory numeric suffix: `{scoreType} {kills}` (`0 0` means no StatTrak value) +- Optional `sd`/`kd` blocks preserve exact placement (slot, scale, rotation, offsets, pattern, paintKit) for stickers/keychains - Float values have trailing zeros stripped (max 8 decimal places); `0.0` becomes `"0"` ### Generate a Steam inspect URL from parameters @@ -123,17 +125,44 @@ $item = new ItemPreviewData( ); $code = GenCode::toGenCode($item); -// "!gen 7 474 306 0.22540508 0 0 0 0 7203 0 0 0 0 0" +// "!gen 7 474 306 0.22540508 0 0 0 0 7203 0 0 0 0 0 0 0" $code = GenCode::toGenCode($item, '!g'); // custom prefix ``` +Exact placement example (includes `sd` block): + +```php +$item = new ItemPreviewData( + defindex: 7, + paintindex: 474, + paintseed: 306, + paintwear: 0.22540508, + stickers: [ + new Sticker( + slot: 1, + stickerId: 7436, + wear: 0.08, + scale: 1.2, + rotation: 123.45, + offsetX: 0.11, + offsetY: -0.22, + offsetZ: 0.33, + pattern: 9, + ), + ], +); + +$code = GenCode::toGenCode($item, ''); +// ... 0 0 sd 1 1 7436 0.08 1.2 123.45 0.11 -0.22 0.33 9 0 +``` + ### Parse a gen code string ```php use VlyDev\Steam\GenCode; -$item = GenCode::parseGenCode('!gen 7 474 306 0.22540508 0 0 0 0 7203 0 0 0 0 0'); +$item = GenCode::parseGenCode('!gen 7 474 306 0.22540508 0 0 0 0 7203 0 0 0 0 0 0 0'); echo $item->defindex; // 7 echo $item->paintindex; // 474 echo $item->paintseed; // 306 @@ -146,7 +175,7 @@ $item2 = GenCode::parseGenCode('7 474 306 0.22540508'); // prefix is optional ```php $code = GenCode::genCodeFromLink('steam://rungame/730/76561202255233023/+csgo_econ_action_preview%20001A...'); -// "!gen 7 474 306 0.22540508" +// "!gen 7 474 306 0.22540508 0 0 0 0 0 0 0 0 0 0 0 0" ``` --- diff --git a/src/GenCode.php b/src/GenCode.php index a3fbe54..7fd5b1c 100644 --- a/src/GenCode.php +++ b/src/GenCode.php @@ -9,9 +9,11 @@ * * Gen codes are space-separated command strings used on community servers: * !gen {defindex} {paintindex} {paintseed} {paintwear} - * !gen {defindex} {paintindex} {paintseed} {paintwear} {s0_id} {s0_wear} ... {s4_id} {s4_wear} [{kc_id} {kc_wear} ...] + * !gen {defindex} {paintindex} {paintseed} {paintwear} {s0_id} {s0_wear} ... {s4_id} {s4_wear} [{kc_id} {kc_wear} ...] {scoreType} {kills} [sd {n} ...] [kd {n} ...] * * Stickers are always padded to 5 slot pairs. Keychains follow without padding. + * StatTrak is appended as two trailing numeric tokens: {scoreType} {kills}. + * Optional sd/kd blocks preserve exact placement/rotation data for round-trips. */ final class GenCode { @@ -68,6 +70,108 @@ private static function serializeStickerPairs(array $stickers, ?int $padTo): arr return $result; } + /** + * Serialize exact sticker/charm placement records. + * + * Record layout: + * slot id wear scale rotation offsetX offsetY offsetZ pattern paintKit + * + * @param Sticker[] $stickers + * @return string[] + */ + private static function serializePlacementDetails(array $stickers, string $marker): array + { + $filtered = array_values(array_filter($stickers, fn(Sticker $s) => $s->stickerId !== 0)); + if ($filtered === []) { + return []; + } + + usort($filtered, fn(Sticker $a, Sticker $b) => $a->slot <=> $b->slot); + + $result = [$marker, (string) count($filtered)]; + foreach ($filtered as $s) { + $result[] = (string) $s->slot; + $result[] = (string) $s->stickerId; + $result[] = self::formatFloat($s->wear ?? 0.0); + $result[] = self::formatFloat($s->scale ?? 0.0); + $result[] = self::formatFloat($s->rotation ?? 0.0); + $result[] = self::formatFloat($s->offsetX ?? 0.0); + $result[] = self::formatFloat($s->offsetY ?? 0.0); + $result[] = self::formatFloat($s->offsetZ ?? 0.0); + $result[] = (string) $s->pattern; + $result[] = (string) ($s->paintKit ?? 0); + } + + return $result; + } + + /** + * Decide whether exact sticker details should be emitted. + * + * @param Sticker[] $stickers + */ + private static function needsStickerDetailBlock(array $stickers): bool + { + foreach ($stickers as $s) { + if ( + $s->stickerId !== 0 + && ( + $s->scale !== null + || $s->rotation !== null + || $s->offsetX !== null + || $s->offsetY !== null + || $s->offsetZ !== null + || $s->pattern !== 0 + || $s->paintKit !== null + ) + ) { + return true; + } + } + + return false; + } + + /** + * @param string[] $tokens + * @param int $start + * @return array{list, int} + */ + private static function parsePlacementDetails(array $tokens, int $start): array + { + $parsed = []; + $count = count($tokens); + + if ($start + 1 >= $count) { + throw new \InvalidArgumentException('Malformed detail block: missing record count.'); + } + + $records = (int) $tokens[$start + 1]; + $i = $start + 2; + + for ($r = 0; $r < $records; $r++) { + if ($i + 9 >= $count) { + throw new \InvalidArgumentException('Malformed detail block: truncated record.'); + } + + $parsed[] = new Sticker( + slot: (int) $tokens[$i], + stickerId: (int) $tokens[$i + 1], + wear: (float) $tokens[$i + 2], + scale: (float) $tokens[$i + 3], + rotation: (float) $tokens[$i + 4], + offsetX: (float) $tokens[$i + 5], + offsetY: (float) $tokens[$i + 6], + offsetZ: (float) $tokens[$i + 7], + pattern: (int) $tokens[$i + 8], + paintKit: (int) $tokens[$i + 9], + ); + $i += 10; + } + + return [$parsed, $i]; + } + /** * Convert an ItemPreviewData to a gen code string. * @@ -85,6 +189,16 @@ public static function toGenCode(ItemPreviewData $item, string $prefix = '!gen') array_push($parts, ...self::serializeStickerPairs($item->stickers, 5)); array_push($parts, ...self::serializeStickerPairs($item->keychains, null)); + array_push($parts, (string) $item->killeaterscoretype, (string) $item->killeatervalue); + + if (self::needsStickerDetailBlock($item->stickers)) { + array_push($parts, ...self::serializePlacementDetails($item->stickers, 'sd')); + } + + // Always include keychain details so charm slot and placement are never lost. + if ($item->keychains !== []) { + array_push($parts, ...self::serializePlacementDetails($item->keychains, 'kd')); + } $payload = implode(' ', $parts); return $prefix !== '' ? "{$prefix} {$payload}" : $payload; @@ -145,7 +259,7 @@ public static function genCodeFromLink(string $hexOrUrl, string $prefix = '!gen' * * Accepts codes like: * "!gen 7 474 306 0.22540508" - * "7 941 2 0.22540508 0 0 0 0 7203 0 0 0 0 0 36 0" + * "7 941 2 0.22540508 0 0 0 0 7203 0 0 0 0 0 36 0 0 0" * * @throws \InvalidArgumentException If the code has fewer than 4 tokens. */ @@ -172,6 +286,60 @@ public static function parseGenCode(string $genCode): ItemPreviewData $paintSeed = (int) $tokens[2]; $paintWear = (float) $tokens[3]; $rest = array_slice($tokens, 4); + $killeaterScoreType = 0; + $killeaterValue = 0; + $detailedStickers = null; + $detailedKeychains = null; + + // Optional detailed blocks: sd/kd + $detailStart = null; + foreach ($rest as $idx => $tok) { + if ($tok === 'sd' || $tok === 'kd') { + $detailStart = $idx; + break; + } + } + + if ($detailStart !== null) { + $detailTokens = array_slice($rest, $detailStart); + $rest = array_slice($rest, 0, $detailStart); + + $i = 0; + while ($i < count($detailTokens)) { + $marker = $detailTokens[$i] ?? null; + if ($marker !== 'sd' && $marker !== 'kd') { + throw new \InvalidArgumentException('Malformed detail blocks: unknown marker token.'); + } + + [$parsed, $nextIndex] = self::parsePlacementDetails($detailTokens, $i); + if ($marker === 'sd') { + $detailedStickers = $parsed; + } else { + $detailedKeychains = $parsed; + } + + $i = $nextIndex; + } + } + + // Compatibility 1: marker-based suffix: "st " + if (count($rest) >= 3) { + $stPos = array_search('st', $rest, true); + if ($stPos !== false && $stPos + 2 < count($rest)) { + $killeaterScoreType = (int) $rest[$stPos + 1]; + $killeaterValue = (int) $rest[$stPos + 2]; + $rest = array_slice($rest, 0, $stPos); + } + } + + // Compatibility 2 (current format): trailing numeric suffix: "... " + // If no marker is present and at least two tokens remain, consume the last two tokens. + if (count($rest) >= 2) { + $last = count($rest) - 1; + $killeaterScoreType = (int) $rest[$last - 1]; + $killeaterValue = (int) $rest[$last]; + $rest = array_slice($rest, 0, $last - 1); + } $stickers = []; $keychains = []; @@ -196,11 +364,21 @@ public static function parseGenCode(string $genCode): ItemPreviewData } } + if ($detailedStickers !== null) { + $stickers = $detailedStickers; + } + + if ($detailedKeychains !== null) { + $keychains = $detailedKeychains; + } + return new ItemPreviewData( defindex: $defIndex, paintindex: $paintIndex, paintseed: $paintSeed, paintwear: $paintWear, + killeaterscoretype: $killeaterScoreType, + killeatervalue: $killeaterValue, stickers: $stickers, keychains: $keychains, ); diff --git a/tests/GenCodeTest.php b/tests/GenCodeTest.php index 8b51cd6..3128d5b 100644 --- a/tests/GenCodeTest.php +++ b/tests/GenCodeTest.php @@ -22,6 +22,8 @@ public function testToGenCodeMinimal(): void $code = GenCode::toGenCode($item); $this->assertStringStartsWith('!gen ', $code); $this->assertStringContainsString('7 474 306 0.22540508', $code); + $this->assertStringEndsWith('0 0', $code); + $this->assertStringNotContainsString(' st ', $code); } public function testToGenCodeDefaultPrefix(): void @@ -97,8 +99,8 @@ public function testToGenCodeAlwaysPadsStickerTo5Slots(): void ); $code = GenCode::toGenCode($item); $tokens = explode(' ', $code); - // !gen + 4 base + 10 sticker tokens = 15 tokens total - $this->assertCount(15, $tokens); + // !gen + 4 base + 10 sticker tokens + 2 stattrak tokens = 17 tokens total + $this->assertCount(17, $tokens); // slot 0: 0 0, slot 1: 0 0, slot 2: 7203 0, slot 3: 0 0, slot 4: 0 0 // tokens: [0]=!gen [1]=7 [2]=474 [3]=306 [4]=0.22540508 [5]=0 [6]=0 [7]=0 [8]=0 [9]=7203 [10]=0 [11]=0 [12]=0 [13]=0 [14]=0 $this->assertSame('0', $tokens[5]); @@ -129,11 +131,13 @@ public function testToGenCodeNoStickersHas5EmptySlots(): void $item = new ItemPreviewData(defindex: 7, paintindex: 474, paintseed: 306, paintwear: 0.5); $code = GenCode::toGenCode($item); $tokens = explode(' ', $code); - // !gen + 4 + 10 = 15 tokens - $this->assertCount(15, $tokens); + // !gen + 4 + 10 + 2 stattrak suffix = 17 tokens + $this->assertCount(17, $tokens); for ($i = 5; $i <= 14; $i++) { $this->assertSame('0', $tokens[$i]); } + $this->assertSame('0', $tokens[15]); + $this->assertSame('0', $tokens[16]); } // ----------------------------------------------------------------------- @@ -151,10 +155,11 @@ public function testToGenCodeKeychainAppendedAfterStickers(): void ); $code = GenCode::toGenCode($item); $tokens = explode(' ', $code); - // !gen + 4 base + 10 sticker + 2 keychain = 17 - $this->assertCount(17, $tokens); + // !gen + 4 base + 10 sticker + 2 keychain + 2 stattrak + kd block (2 + 10) = 31 + $this->assertCount(31, $tokens); $this->assertSame('36', $tokens[15]); $this->assertSame('0', $tokens[16]); + $this->assertContains('kd', $tokens); } public function testToGenCodeKeychainNotPadded(): void @@ -169,7 +174,25 @@ public function testToGenCodeKeychainNotPadded(): void $code = GenCode::toGenCode($item); $tokens = explode(' ', $code); // Only slot 2 keychain, not padded to 3 slots - $this->assertCount(17, $tokens); + $this->assertCount(31, $tokens); + $kdPos = array_search('kd', $tokens, true); + $this->assertNotFalse($kdPos); + $this->assertSame('2', $tokens[$kdPos + 2]); // explicit keychain slot in detail block + } + + public function testToGenCodeIncludesStatTrakValues(): void + { + $item = new ItemPreviewData( + defindex: 7, + paintindex: 474, + paintseed: 306, + paintwear: 0.22540508, + killeaterscoretype: 0, + killeatervalue: 1337, + ); + $code = GenCode::toGenCode($item, ''); + $this->assertStringEndsWith('0 1337', $code); + $this->assertStringNotContainsString(' st ', $code); } // ----------------------------------------------------------------------- @@ -217,7 +240,7 @@ public function testParseGenCodeTooFewTokensNoPrefix(): void public function testParseGenCodeStickersFromPaddedSlots(): void { // slot2 = 7203, others empty - $item = GenCode::parseGenCode('!gen 7 474 306 0.22540508 0 0 0 0 7203 0 0 0 0 0'); + $item = GenCode::parseGenCode('!gen 7 474 306 0.22540508 0 0 0 0 7203 0 0 0 0 0 0 0'); $this->assertCount(1, $item->stickers); $this->assertSame(7203, $item->stickers[0]->stickerId); $this->assertSame(2, $item->stickers[0]->slot); @@ -225,7 +248,7 @@ public function testParseGenCodeStickersFromPaddedSlots(): void public function testParseGenCodeMultipleStickers(): void { - $item = GenCode::parseGenCode('!gen 7 474 306 0.22540508 7436 0 5144 0 0 0 0 0 0 0'); + $item = GenCode::parseGenCode('!gen 7 474 306 0.22540508 7436 0 5144 0 0 0 0 0 0 0 0 0'); $this->assertCount(2, $item->stickers); $ids = array_map(fn(Sticker $s) => $s->stickerId, $item->stickers); $this->assertContains(7436, $ids); @@ -234,7 +257,7 @@ public function testParseGenCodeMultipleStickers(): void public function testParseGenCodeStickerWear(): void { - $item = GenCode::parseGenCode('!gen 7 474 306 0.5 7203 0.25 0 0 0 0 0 0 0 0'); + $item = GenCode::parseGenCode('!gen 7 474 306 0.5 7203 0.25 0 0 0 0 0 0 0 0 0 0'); $this->assertCount(1, $item->stickers); $this->assertNotNull($item->stickers[0]->wear); $this->assertEqualsWithDelta(0.25, $item->stickers[0]->wear, 1e-6); @@ -246,6 +269,20 @@ public function testParseGenCodeNoStickersWhenLessThan10Tokens(): void $this->assertCount(0, $item->stickers); } + public function testParseGenCodeStatTrakSuffix(): void + { + $item = GenCode::parseGenCode('7 474 306 0.5 st 0 777'); + $this->assertSame(0, $item->killeaterscoretype); + $this->assertSame(777, $item->killeatervalue); + } + + public function testParseGenCodeLegacyWithoutStatTrakSuffixDefaultsToZero(): void + { + $item = GenCode::parseGenCode('7 474 306 0.5'); + $this->assertSame(0, $item->killeaterscoretype); + $this->assertSame(0, $item->killeatervalue); + } + // ----------------------------------------------------------------------- // parseGenCode — keychains // ----------------------------------------------------------------------- @@ -253,7 +290,7 @@ public function testParseGenCodeNoStickersWhenLessThan10Tokens(): void public function testParseGenCodeKeychain(): void { // 10 sticker tokens + 2 keychain tokens - $item = GenCode::parseGenCode('7 941 2 0.22540508 0 0 0 0 0 0 0 0 0 0 36 0'); + $item = GenCode::parseGenCode('7 941 2 0.22540508 0 0 0 0 0 0 0 0 0 0 36 0 0 0'); $this->assertCount(1, $item->keychains); $this->assertSame(36, $item->keychains[0]->stickerId); } @@ -307,6 +344,94 @@ public function testRoundtripWithKeychain(): void $parsed = GenCode::parseGenCode(GenCode::toGenCode($original)); $this->assertCount(1, $parsed->keychains); $this->assertSame(36, $parsed->keychains[0]->stickerId); + $this->assertSame(0, $parsed->keychains[0]->slot); + } + + public function testRoundtripStickerPreservesExactPlacementAndRotation(): void + { + $original = new ItemPreviewData( + defindex: 7, + paintindex: 474, + paintseed: 306, + paintwear: 0.22540508, + stickers: [ + new Sticker( + slot: 1, + stickerId: 7436, + wear: 0.08, + scale: 1.2, + rotation: 123.45, + offsetX: 0.11, + offsetY: -0.22, + offsetZ: 0.33, + pattern: 9, + ), + ], + ); + + $parsed = GenCode::parseGenCode(GenCode::toGenCode($original)); + $this->assertCount(1, $parsed->stickers); + $s = $parsed->stickers[0]; + $this->assertSame(1, $s->slot); + $this->assertSame(7436, $s->stickerId); + $this->assertEqualsWithDelta(1.2, $s->scale ?? 0.0, 1e-6); + $this->assertEqualsWithDelta(123.45, $s->rotation ?? 0.0, 1e-6); + $this->assertEqualsWithDelta(0.11, $s->offsetX ?? 0.0, 1e-6); + $this->assertEqualsWithDelta(-0.22, $s->offsetY ?? 0.0, 1e-6); + $this->assertEqualsWithDelta(0.33, $s->offsetZ ?? 0.0, 1e-6); + $this->assertSame(9, $s->pattern); + } + + public function testRoundtripKeychainPreservesExactPlacementAndRotation(): void + { + $original = new ItemPreviewData( + defindex: 7, + paintindex: 474, + paintseed: 306, + paintwear: 0.22540508, + keychains: [ + new Sticker( + slot: 3, + stickerId: 36, + wear: 0.07, + scale: 0.95, + rotation: 44.5, + offsetX: -0.15, + offsetY: 0.25, + offsetZ: 0.35, + pattern: 2, + paintKit: 929, + ), + ], + ); + + $parsed = GenCode::parseGenCode(GenCode::toGenCode($original)); + $this->assertCount(1, $parsed->keychains); + $kc = $parsed->keychains[0]; + $this->assertSame(3, $kc->slot); + $this->assertSame(36, $kc->stickerId); + $this->assertEqualsWithDelta(0.95, $kc->scale ?? 0.0, 1e-6); + $this->assertEqualsWithDelta(44.5, $kc->rotation ?? 0.0, 1e-6); + $this->assertEqualsWithDelta(-0.15, $kc->offsetX ?? 0.0, 1e-6); + $this->assertEqualsWithDelta(0.25, $kc->offsetY ?? 0.0, 1e-6); + $this->assertEqualsWithDelta(0.35, $kc->offsetZ ?? 0.0, 1e-6); + $this->assertSame(2, $kc->pattern); + $this->assertSame(929, $kc->paintKit); + } + + public function testRoundtripWithStatTrak(): void + { + $original = new ItemPreviewData( + defindex: 7, + paintindex: 474, + paintseed: 306, + paintwear: 0.22540508, + killeaterscoretype: 0, + killeatervalue: 451, + ); + $parsed = GenCode::parseGenCode(GenCode::toGenCode($original)); + $this->assertSame(0, $parsed->killeaterscoretype); + $this->assertSame(451, $parsed->killeatervalue); } // ----------------------------------------------------------------------- @@ -324,10 +449,13 @@ public function testToGenCodeKeychainWithPaintKitAppendsPaintKit(): void ); $code = GenCode::toGenCode($item, ''); $tokens = explode(' ', $code); - // last three tokens should be: 37 0 929 - $this->assertSame('37', $tokens[count($tokens) - 3]); - $this->assertSame('0', $tokens[count($tokens) - 2]); - $this->assertSame('929', $tokens[count($tokens) - 1]); + $kdPos = array_search('kd', $tokens, true); + $this->assertNotFalse($kdPos); + // keychain legacy tuple is before stattrak; exact paintKit is also mirrored in kd block + $this->assertSame('37', $tokens[$kdPos - 5]); + $this->assertSame('0', $tokens[$kdPos - 4]); + $this->assertSame('929', $tokens[$kdPos - 3]); + $this->assertSame('929', $tokens[$kdPos + 11]); } public function testToGenCodeKeychainWithoutPaintKitNoExtraToken(): void @@ -341,9 +469,11 @@ public function testToGenCodeKeychainWithoutPaintKitNoExtraToken(): void ); $code = GenCode::toGenCode($item, ''); $tokens = explode(' ', $code); - // last two tokens should be: 36 0 - $this->assertSame('36', $tokens[count($tokens) - 2]); - $this->assertSame('0', $tokens[count($tokens) - 1]); + $kdPos = array_search('kd', $tokens, true); + $this->assertNotFalse($kdPos); + $this->assertSame('36', $tokens[$kdPos - 4]); + $this->assertSame('0', $tokens[$kdPos - 3]); + $this->assertSame('0', $tokens[$kdPos + 11]); } // ----------------------------------------------------------------------- @@ -355,9 +485,12 @@ public function testGenCodeFromLinkSlabUrlEndsWithPaintKit(): void $slabUrl = 'steam://run/730//+csgo_econ_action_preview%20819181994A8BA181A982B189E981F181238086898191A4E1208698F309C9'; $code = GenCode::genCodeFromLink($slabUrl, ''); $tokens = explode(' ', $code); - $this->assertSame('37', $tokens[count($tokens) - 3]); - $this->assertSame('0', $tokens[count($tokens) - 2]); - $this->assertSame('929', $tokens[count($tokens) - 1]); + $kdPos = array_search('kd', $tokens, true); + $this->assertNotFalse($kdPos); + $this->assertSame('37', $tokens[$kdPos - 5]); + $this->assertSame('0', $tokens[$kdPos - 4]); + $this->assertSame('929', $tokens[$kdPos - 3]); + $this->assertSame('929', $tokens[$kdPos + 11]); } // -----------------------------------------------------------------------