-
Notifications
You must be signed in to change notification settings - Fork 3
Add sync log endpoint for desync log uploads #45
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| # Ensure shell scripts always use LF line endings on all platforms. | ||
| # Without this, Windows git (autocrlf=true) will convert LF->CRLF on checkout, | ||
| # causing "no such file or directory" errors when Docker runs the entrypoint. | ||
| *.sh text eol=lf | ||
|
|
||
| # Standard: force LF for all text files committed to the repo | ||
| * text=auto |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,6 +7,8 @@ events { | |
| } | ||
|
|
||
| http { | ||
| client_max_body_size 15m; | ||
|
|
||
| server { | ||
| listen 80; | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,45 @@ | ||
| """Django admin registrations for Kirovy models. | ||
|
|
||
| The sync log admin is the primary reason this file exists. It provides a | ||
| grouped view of sync logs by game session with a direct link to the | ||
| compare endpoint for side-by-side log analysis. | ||
| """ | ||
|
|
||
| from django.contrib import admin | ||
| from django.utils.html import format_html | ||
|
|
||
| from kirovy.models.sync_log import SyncLog | ||
|
|
||
|
|
||
| @admin.register(SyncLog) | ||
| class SyncLogAdmin(admin.ModelAdmin): | ||
| list_display = ( | ||
| "player_name", | ||
| "sync_file_index", | ||
| "game_hash_short", | ||
| "map_name", | ||
| "game_mode", | ||
| "game_slug", | ||
| "ip_address", | ||
| "uploaded_at", | ||
| "compare_link", | ||
| ) | ||
| list_filter = ("cnc_game__slug", "uploaded_at") | ||
| search_fields = ("game_hash", "player_name", "map_sha1", "map_name") | ||
| readonly_fields = ("id", "uploaded_at", "game_hash", "ip_address", "compare_link") | ||
| ordering = ("-uploaded_at",) | ||
|
|
||
| @admin.display(description="Game", ordering="cnc_game__slug") | ||
| def game_slug(self, obj: SyncLog) -> str: | ||
| return obj.cnc_game.slug if obj.cnc_game else "—" | ||
|
|
||
| @admin.display(description="Game Hash") | ||
| def game_hash_short(self, obj: SyncLog) -> str: | ||
| return obj.game_hash[:12] + "…" | ||
|
|
||
| @admin.display(description="Compare Session") | ||
| def compare_link(self, obj: SyncLog) -> str: | ||
| return format_html( | ||
| '<a href="/sync-logs/compare/{}/view/" target="_blank">Compare logs</a>', | ||
| obj.game_hash, | ||
| ) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,69 @@ | ||
| import uuid | ||
|
|
||
| import django.db.models.deletion | ||
| from django.db import migrations, models | ||
|
|
||
|
|
||
| class Migration(migrations.Migration): | ||
|
|
||
| dependencies = [ | ||
| ("kirovy", "0021_remove_cncmapfile_kirovy_cncm_cnc_map_a1e8af_idx_and_more"), | ||
| ] | ||
|
|
||
| operations = [ | ||
| migrations.CreateModel( | ||
| name="SyncLog", | ||
| fields=[ | ||
| ("id", models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), | ||
| ( | ||
| "game_hash", | ||
| models.CharField( | ||
| db_index=True, | ||
| help_text="SHA1 of (seed + map_sha1 + game_slug). Same for all players in the same game session.", | ||
| max_length=64, | ||
| ), | ||
| ), | ||
| ("player_name", models.CharField(max_length=64)), | ||
| ( | ||
| "sync_file_index", | ||
| models.SmallIntegerField( | ||
| help_text="Player slot index from the SYNC filename (0-7). e.g. SYNC2.TXT → index 2." | ||
| ), | ||
| ), | ||
| ("map_sha1", models.CharField(blank=True, max_length=40)), | ||
| ("map_name", models.CharField(blank=True, max_length=255)), | ||
| ("game_mode", models.CharField(blank=True, max_length=128)), | ||
| ( | ||
| "cnc_game", | ||
| models.ForeignKey( | ||
| blank=True, | ||
| null=True, | ||
| on_delete=django.db.models.deletion.SET_NULL, | ||
| related_name="sync_logs", | ||
| to="kirovy.cncgame", | ||
| ), | ||
| ), | ||
| ("file", models.FileField(upload_to="sync_logs/%Y/%m/%d/")), | ||
| ("ip_address", models.GenericIPAddressField()), | ||
| ("uploaded_at", models.DateTimeField(auto_now_add=True)), | ||
| ], | ||
| options={ | ||
| "app_label": "kirovy", | ||
| }, | ||
| ), | ||
| migrations.AddIndex( | ||
| model_name="synclog", | ||
| index=models.Index(fields=["game_hash", "sync_file_index"], name="kirovy_sync_game_ha_idx"), | ||
| ), | ||
| migrations.AddIndex( | ||
| model_name="synclog", | ||
| index=models.Index(fields=["uploaded_at"], name="kirovy_sync_uploade_idx"), | ||
| ), | ||
| migrations.AddConstraint( | ||
| model_name="synclog", | ||
| constraint=models.UniqueConstraint( | ||
| fields=["game_hash", "sync_file_index", "ip_address"], | ||
| name="unique_sync_log_per_slot_per_machine", | ||
| ), | ||
| ), | ||
| ] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| from django.db import migrations, models | ||
|
|
||
|
|
||
| class Migration(migrations.Migration): | ||
|
|
||
| dependencies = [ | ||
| ("kirovy", "0022_synclog"), | ||
| ] | ||
|
|
||
| operations = [ | ||
| migrations.AddField( | ||
| model_name="synclog", | ||
| name="file_size", | ||
| field=models.PositiveIntegerField( | ||
| default=0, | ||
| help_text="File size in bytes. Used for storage cap enforcement without filesystem calls.", | ||
| ), | ||
| ), | ||
| ] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -20,6 +20,10 @@ class CncUserManager(models.Manager): | |
| constants.LegacyUploadUser.CNCNET_ID, | ||
| } | ||
|
|
||
| def get_by_natural_key(self, cncnet_id): | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Remove: The API isn't an authenticator, the ladder API controls that |
||
| """Required by ModelBackend for Django admin password-based login.""" | ||
| return super().get_queryset().get(cncnet_id=cncnet_id) | ||
|
|
||
| def find_by_cncnet_id(self, cncnet_id: int) -> t.Union["CncUser", None]: | ||
| return super().get_queryset().filter(cncnet_id=cncnet_id).first() | ||
|
|
||
|
|
@@ -141,6 +145,16 @@ def is_admin(self) -> bool: | |
| self.refresh_from_db(fields=["group"]) | ||
| return self.CncnetUserGroup.is_admin(self.group) | ||
|
|
||
| @property | ||
| def is_superuser(self) -> bool: | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Remove: The API isn't an authenticator, the ladder API controls that |
||
| return self.is_admin | ||
|
|
||
| def has_perm(self, perm, obj=None) -> bool: | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Remove: The API isn't an authenticator, the ladder API controls that |
||
| return self.is_staff | ||
|
|
||
| def has_module_perms(self, app_label) -> bool: | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Remove: The API isn't an authenticator, the ladder API controls that |
||
| return self.is_staff | ||
|
|
||
| @staticmethod | ||
| def create_or_update_from_cncnet(user_dto: CncnetUserInfo) -> "CncUser": | ||
| """Create or Update a user object, based on the user DTO, from the CnCNet ladder API. | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,64 @@ | ||
| import uuid | ||
|
|
||
| from django.db import models | ||
|
|
||
| from kirovy.models.cnc_game import CncGame | ||
|
|
||
|
|
||
| class SyncLog(models.Model): | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Inherit from KirovyModel |
||
| """A sync error log uploaded from a CnCNet game client. | ||
|
|
||
| When a multiplayer game desyncs, the game engine writes SYNC{n}.TXT files (one per player slot). | ||
| Clients upload these so admins can compare logs across machines to diagnose the desync. | ||
|
|
||
| All clients in the same game session share the same ``game_hash`` (derived from the random seed | ||
| and map SHA1), making it easy to group and compare logs from different players. | ||
| """ | ||
|
|
||
| id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not needed if you inherit |
||
|
|
||
| game_hash = models.CharField( | ||
| max_length=64, | ||
| db_index=True, | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why the index on this? It will change for every lobby. |
||
| help_text='SHA1 of "{mapSha1}|{randomSeed}|{gameSlug}" as a lowercase hex string. Same for all players in the same game session.', | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It'd be better to store these as separate fields and use the game_id to get the slug |
||
| ) | ||
| player_name = models.CharField(max_length=64) | ||
| sync_file_index = models.SmallIntegerField( | ||
| help_text="Player slot index from the SYNC filename (0-7). e.g. SYNC2.TXT → index 2.", | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Let's call it |
||
| ) | ||
|
|
||
| map_sha1 = models.CharField(max_length=40, blank=True) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Use the hash to look up the map file foreign key when the logs are uploaded.
|
||
| map_name = models.CharField(max_length=255, blank=True) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Remove. It will be on the map itself. |
||
| game_mode = models.CharField(max_length=128, blank=True) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It'd be better to check the game mode table first and see if we can link to a foreign key. |
||
| cnc_game = models.ForeignKey( | ||
| CncGame, | ||
| on_delete=models.SET_NULL, | ||
| null=True, | ||
| blank=True, | ||
| related_name="sync_logs", | ||
| ) | ||
|
|
||
| file = models.FileField(upload_to="sync_logs/%Y/%m/%d/") | ||
| file_size = models.PositiveIntegerField( | ||
| default=0, | ||
| help_text="File size in bytes. Used for storage cap enforcement without filesystem calls.", | ||
| ) | ||
| ip_address = models.GenericIPAddressField() | ||
| uploaded_at = models.DateTimeField(auto_now_add=True) | ||
|
|
||
| class Meta: | ||
| app_label = "kirovy" | ||
| indexes = [ | ||
| models.Index(fields=["game_hash", "sync_file_index"]), | ||
| models.Index(fields=["uploaded_at"]), | ||
| ] | ||
| # One sync file per player slot per machine per game session. | ||
| constraints = [ | ||
| models.UniqueConstraint( | ||
| fields=["game_hash", "sync_file_index", "ip_address"], | ||
| name="unique_sync_log_per_slot_per_machine", | ||
| ) | ||
| ] | ||
|
|
||
| def __str__(self): | ||
| return f"SyncLog[{self.player_name} SYNC{self.sync_file_index} {self.game_hash[:8]}]" | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -241,6 +241,10 @@ | |
|
|
||
| AUTH_USER_MODEL = "kirovy.CncUser" | ||
|
|
||
| AUTHENTICATION_BACKENDS = [ | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Remove: The API isn't an authenticator, the ladder API controls that |
||
| "django.contrib.auth.backends.ModelBackend", # enables password-based login for Django admin | ||
| ] | ||
|
|
||
|
|
||
| RUN_ENVIRONMENT = get_env_var("RUN_ENVIRONMENT", settings_constants.RunEnvironment.PRODUCTION, run_environment_valid) | ||
| """attr: Defines which type of environment we are running on. Useful for debug logic.""" | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I am pretty against using django admin due to prior negative experiences and database integrity issues.
I am open to discussing it, but we'd need to find a way to make it use the ladder for auth.