Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 3 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ Unless explicitly stated otherwise, the repository license applies to the source
- Case opening with roulette animation
- Optional X-Ray opening mechanic
- Souvenir packages with tournament-based dates
- Skin pattern seed and finish variant support, including phase-aware finishes
- Major tournament section covering CS:GO and CS2 eras
- Major teams and players browsing
- Operation and Armory reward collections
Expand Down Expand Up @@ -144,17 +145,13 @@ The source code in this repository is licensed under `AGPL-3.0`.

The project is actively evolving, with current work focused on:

- expanding the simulation layer beyond basic opening flows
- expanding the simulation layer beyond basic opening flows with deeper item metadata
- improving long-term data quality for tournaments, teams, and players
- continuing UI/codebase refactoring to reduce duplicated screen logic
- preparing larger simulation features such as pattern support and collection tracking
- preparing larger progression features such as collection tracking and ownership history

## Roadmap

### v0.12

- Skin pattern and finish seed support, including knife phases, gem variants, fade-style finishes, and other pattern-driven outcomes

### v0.13

- Inventory or item ownership tracking in some form
Expand Down
35 changes: 35 additions & 0 deletions lib/data/models/skin_group_dto.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import 'skin_dto.dart';

class SkinGroupDto {
final String key;
final SkinDto primary;
final List<SkinDto> variants;

const SkinGroupDto({
required this.key,
required this.primary,
required this.variants,
});

String get id => primary.id;
String get name => primary.name;
String get skinImage => primary.skinImage;
String get itemDisplayName => primary.itemDisplayName;
String get rarity => primary.rarity;
String get weaponType => primary.weaponType;
String get itemKind => primary.itemKind;
String? get collection => primary.collection;
bool get isSpecialItem => primary.isSpecialItem;
bool get hasMultipleVariants => variants.length > 1;

List<String> get variantLabels {
final labels = <String>{};
for (final variant in variants) {
final label = variant.displayVariant;
if (label != null && label.isNotEmpty) {
labels.add(label);
}
}
return labels.toList();
}
}
1 change: 1 addition & 0 deletions lib/data/repositories/local_data_repository.dart
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import '../models/pin_content_dto.dart';
import '../models/pin_dto.dart';
import '../models/reward_collection_content_dto.dart';
import '../models/skin_dto.dart';
import '../models/skin_group_dto.dart';
import '../models/sticker_content_dto.dart';
import '../models/sticker_dto.dart';
import '../models/tournament_dto.dart';
Expand Down
82 changes: 82 additions & 0 deletions lib/data/repositories/local_data_repository_queries.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1047,6 +1047,57 @@ mixin _LocalDataRepositoryQueries on _LocalDataRepositoryLoaders {
result.sort(_compareCollectibleCollectionAsc);
return result;
}

Future<List<SkinGroupDto>> loadSkinGroups() async {
final skins = await loadSkins();
final grouped = <String, List<SkinDto>>{};

for (final skin in skins) {
grouped.putIfAbsent(_skinGroupKey(skin), () => <SkinDto>[]).add(skin);
}

final result = grouped.entries.map((entry) {
final variants = List<SkinDto>.from(entry.value)
..sort(_compareSkinVariantsForGroup);
return SkinGroupDto(
key: entry.key,
primary: variants.first,
variants: variants,
);
}).toList();

result.sort((a, b) {
final specialCompare = (a.isSpecialItem ? 1 : 0).compareTo(
b.isSpecialItem ? 1 : 0,
);
if (specialCompare != 0) return specialCompare;

final rarityCompare = _rarityOrder(
a.primary,
).compareTo(_rarityOrder(b.primary));
if (rarityCompare != 0) return rarityCompare;

final weaponCompare = a.itemDisplayName.compareTo(b.itemDisplayName);
if (weaponCompare != 0) return weaponCompare;

return a.name.compareTo(b.name);
});

return result;
}

Future<List<SkinDto>> loadSkinVariantsForSkin(String skinId) async {
final skins = await loadSkins();
final selected = skins.firstWhere(
(skin) => skin.id == skinId,
orElse: () => throw StateError('Skin not found: $skinId'),
);
final groupKey = _skinGroupKey(selected);
final result =
skins.where((skin) => _skinGroupKey(skin) == groupKey).toList()
..sort(_compareSkinVariantsForGroup);
return result;
}
}

class _TournamentBuilder {
Expand Down Expand Up @@ -1226,3 +1277,34 @@ int _placeRank(String? place) {
final start = int.tryParse(match.group(1) ?? '');
return start ?? (1 << 20);
}

String _skinGroupKey(SkinDto skin) {
return [
skin.itemKind,
skin.itemId,
skin.name.trim().toLowerCase(),
(skin.finishCatalogName ?? '').trim().toLowerCase(),
(skin.collection ?? '').trim().toLowerCase(),
].join('|');
}

int _compareSkinVariantsForGroup(SkinDto a, SkinDto b) {
final variantCompare = _skinVariantOrder(a).compareTo(_skinVariantOrder(b));
if (variantCompare != 0) return variantCompare;
return int.parse(a.id).compareTo(int.parse(b.id));
}

int _skinVariantOrder(SkinDto skin) {
return switch ((skin.displayVariant ?? '').trim()) {
'' => 0,
'Phase 1' => 1,
'Phase 2' => 2,
'Phase 3' => 3,
'Phase 4' => 4,
'Ruby' => 5,
'Sapphire' => 6,
'Black Pearl' => 7,
'Emerald' => 8,
_ => 100,
};
}
68 changes: 66 additions & 2 deletions lib/domain/container_simulator_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,34 @@ import 'case_odds.dart';
import 'dropped_skin.dart';
import 'package_odds.dart';
import 'skin_float_helper.dart';
import 'skin_pattern_helper.dart';
import 'special_item_variant_helper.dart';
import 'terminal_offer.dart';

class ContainerSimulatorService {
final Random _random = Random();

int? _generatePatternSeedForSkin(SkinDto skin, List<SkinDto> sourcePool) {
if (!SkinPatternHelper.hasExplicitPhaseVariant(skin)) {
return SkinPatternHelper.generateSeed(random: _random, skin: skin);
}

final family = sourcePool
.where(
(candidate) =>
candidate.id == skin.id ||
SpecialItemVariantHelper.familyKeyForSkin(candidate) ==
SpecialItemVariantHelper.familyKeyForSkin(skin),
)
.toList();

return SkinPatternHelper.generateSeed(
random: _random,
skin: skin,
siblingVariants: family,
);
}

DroppedSkin openCase({
required List<SkinDto> skins,
required ContainerDto containerDto,
Expand All @@ -37,6 +60,7 @@ class ContainerSimulatorService {
isSouvenir: false,
skinFloat: wear.floatValue,
exterior: wear.exterior,
patternSeed: _generatePatternSeedForSkin(guaranteedSkin, skins),
);
}

Expand Down Expand Up @@ -76,6 +100,7 @@ class ContainerSimulatorService {
isSouvenir: isSouvenir,
skinFloat: value,
exterior: exterior,
patternSeed: _generatePatternSeedForSkin(selectedSkin, skins),
);
}

Expand Down Expand Up @@ -113,6 +138,7 @@ class ContainerSimulatorService {
isStatTrak: isStatTrak,
skinFloat: value,
exterior: exterior,
patternSeed: _generatePatternSeedForSkin(skin, skins),
offerIndex: index + 1,
);
});
Expand All @@ -126,7 +152,21 @@ class ContainerSimulatorService {
throw Exception('No skins available for rarity: $odds');
}

return filtered[_random.nextInt(filtered.length)];
if (odds == CaseOdds.specialItem) {
return _selectSpecialItemSkin(filtered);
}

return _selectWeightedVariantSkin(filtered);
}

SkinDto _selectSpecialItemSkin(List<SkinDto> skins) {
final families = SpecialItemVariantHelper.groupFamilies(skins);
if (families.isEmpty) {
throw Exception('No special items available');
}

final family = families[_random.nextInt(families.length)];
return SpecialItemVariantHelper.rollVariant(_random, family);
}

SkinDto _selectPackageSkin(List<SkinDto> skins) {
Expand All @@ -152,7 +192,31 @@ class ContainerSimulatorService {
return skins[_random.nextInt(skins.length)];
}

return filtered[_random.nextInt(filtered.length)];
return _selectWeightedVariantSkin(filtered);
}

SkinDto _selectWeightedVariantSkin(List<SkinDto> skins) {
final families = SpecialItemVariantHelper.groupFamilies(skins);
if (families.isEmpty) {
throw Exception('No skins available');
}

final hasWeightedFamilies = families.any(
(family) =>
family.length > 1 &&
SpecialItemVariantHelper.hasConfiguredVariantWeights(family),
);

if (!hasWeightedFamilies) {
return skins[_random.nextInt(skins.length)];
}

final family = families[_random.nextInt(families.length)];
if (family.length == 1) {
return family.first;
}

return SpecialItemVariantHelper.rollVariant(_random, family);
}

CaseOdds _getRandomCaseOdds() {
Expand Down
2 changes: 2 additions & 0 deletions lib/domain/dropped_skin.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@ class DroppedSkin {
final bool isSouvenir;
final double? skinFloat;
final String? exterior;
final int? patternSeed;

const DroppedSkin({
required this.skin,
required this.isStatTrak,
required this.isSouvenir,
required this.skinFloat,
required this.exterior,
required this.patternSeed,
});

bool get isVanillaKnife => skin.isKnife && skin.name == 'Vanilla';
Expand Down
5 changes: 5 additions & 0 deletions lib/domain/operation_collection_simulator_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import '../data/models/skin_dto.dart';
import 'dropped_skin.dart';
import 'package_odds.dart';
import 'skin_float_helper.dart';
import 'skin_pattern_helper.dart';

class OperationCollectionSimulatorService {
final Random _random = Random();
Expand All @@ -30,6 +31,10 @@ class OperationCollectionSimulatorService {
isSouvenir: false,
skinFloat: wear.floatValue,
exterior: wear.exterior,
patternSeed: SkinPatternHelper.generateSeed(
random: _random,
skin: selectedSkin,
),
);
}

Expand Down
5 changes: 5 additions & 0 deletions lib/domain/reward_collection_simulator_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import '../data/models/skin_dto.dart';
import 'dropped_skin.dart';
import 'package_odds.dart';
import 'skin_float_helper.dart';
import 'skin_pattern_helper.dart';

class RewardCollectionSimulatorService {
final Random _random = Random();
Expand All @@ -30,6 +31,10 @@ class RewardCollectionSimulatorService {
isSouvenir: false,
skinFloat: wear.floatValue,
exterior: wear.exterior,
patternSeed: SkinPatternHelper.generateSeed(
random: _random,
skin: selectedSkin,
),
);
}

Expand Down
Loading
Loading