basic noita parsing

This commit is contained in:
Loïc Gremaud 2026-05-10 01:45:29 +02:00
parent 119fdc2a51
commit 69b6b46ee2
Signed by: Legrems
GPG Key ID: D4620E6DF3E0121D
14 changed files with 1117 additions and 38 deletions

View File

@ -1 +1,36 @@
# Register your models here.
from django.contrib import admin
from .models import LogfileSubmission, Objectiv, ObjectivPoint
@admin.register(LogfileSubmission)
class LogfileSubmissionAdmin(admin.ModelAdmin):
list_display = ("id", "user", "content_type", "file_size", "created_at", "processed")
list_filter = ("content_type", "processed", "created_at")
search_fields = ("id", "user__username")
readonly_fields = ("id", "created_at", "updated_at")
fieldsets = (
("Identification", {"fields": ("id",)}),
("File Information", {"fields": ("file", "content_type", "file_size")}),
("User", {"fields": ("user",)}),
("Timestamps", {"fields": ("created_at", "updated_at")}),
("Processing", {"fields": ("processed",)}),
)
@admin.register(Objectiv)
class ObjectivAdmin(admin.ModelAdmin):
list_display = ("objectiv_id", "user", "count")
list_filter = ("objectiv_id", "user")
search_fields = ("objectiv_id", "user__username")
readonly_fields = ("user",)
@admin.register(ObjectivPoint)
class ObjectivPointAdmin(admin.ModelAdmin):
list_display = ("objectiv_id", "display_string", "max_count", "point")
list_filter = ("objectiv_id",)
search_fields = ("objectiv_id", "display_string")
fieldsets = (
("Objective Information", {"fields": ("objectiv_id", "display_string")}),
("Scoring", {"fields": ("max_count", "point")}),
)

View File

@ -3,13 +3,20 @@ from django.core.files.base import ContentFile
from ninja import Router, File
from ninja.files import UploadedFile
from .models import LogfileSubmission
from noita.schemas import ObjectivOut
from .models import LogfileSubmission, Objectiv
from .schemas import NoitaSubmissionOut
router = Router()
@router.get("objectives", response=list[ObjectivOut])
def get_my_objectives(request: HttpRequest):
return Objectiv.objects.order_by("-count").filter(user=request.user)
@router.post("submit", response=NoitaSubmissionOut)
def submit_log_file(request: HttpRequest, file: UploadedFile = File(...)):
"""

View File

@ -0,0 +1,38 @@
# Generated by Django 5.2.7 on 2026-05-09 23:05
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("noita", "0002_rename_submission_logfilesubmission"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="Objectiv",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("updated_at", models.DateTimeField()),
("objectiv_id", models.CharField(max_length=64)),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
),
),
],
),
]

View File

@ -0,0 +1,17 @@
# Generated by Django 5.2.7 on 2026-05-09 23:06
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("noita", "0003_objectiv"),
]
operations = [
migrations.AddField(
model_name="objectiv",
name="count",
field=models.IntegerField(default=1),
),
]

View File

@ -0,0 +1,16 @@
# Generated by Django 5.2.7 on 2026-05-09 23:21
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("noita", "0004_objectiv_count"),
]
operations = [
migrations.RemoveField(
model_name="objectiv",
name="updated_at",
),
]

View File

@ -0,0 +1,30 @@
# Generated by Django 5.2.7 on 2026-05-09 23:44
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("noita", "0005_remove_objectiv_updated_at"),
]
operations = [
migrations.CreateModel(
name="ObjectivPoint",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("objectiv_id", models.CharField(max_length=64, unique=True)),
("display_string", models.CharField(max_length=255)),
("max_count", models.IntegerField(default=1)),
("point", models.IntegerField(default=0)),
],
),
]

View File

@ -42,3 +42,18 @@ class LogfileSubmission(models.Model):
updated_at = models.DateTimeField(auto_now=True)
processed = models.BooleanField(default=False)
class Objectiv(models.Model):
objectiv_id = models.CharField(max_length=64)
user = models.ForeignKey(User, on_delete=models.CASCADE)
count = models.IntegerField(default=1)
class ObjectivPoint(models.Model):
objectiv_id = models.CharField(max_length=64, unique=True)
display_string = models.CharField(max_length=255)
max_count = models.IntegerField(default=1)
point = models.IntegerField(default=0)

View File

@ -1,9 +1,17 @@
from typing import Optional
from pydantic import BaseModel
from datetime import datetime
from ninja import Schema, ModelSchema
from noita.models import Objectiv
class NoitaSubmissionOut(BaseModel):
class ObjectivOut(ModelSchema):
class Meta:
model = Objectiv
fields = ["objectiv_id", "count"]
class NoitaSubmissionOut(Schema):
id: str
user_id: Optional[int]
username: Optional[str]
@ -11,6 +19,3 @@ class NoitaSubmissionOut(BaseModel):
content_type: str
created_at: datetime
processed: bool
class Config:
from_attributes = True

View File

@ -0,0 +1,291 @@
"""
Decode a polylan_mod_log.txt.
Hash format: sha1(seed|timestamp|id) timestamp is bound into the hash so
it cannot be altered without breaking the signature.
Seed-less entries (INIT, DEBUG, mod checks) use sha1(id) with no seed or
timestamp those are resolved via a static lookup.
Usage:
python decode_log.py [path/to/polylan_mod_log.txt]
Default path: ~/.local/share/Steam/steamapps/common/Noita/polylan_mod_log.txt
"""
import hashlib
import re
from functools import cache
from noita.services.spells import ALL_SPELLS, ALL_PERKS
SEED_POOL = [
# General good seeds
3154823,
3718311,
10064758,
123156801,
1024089369,
1026967166,
# Pacifist seeds
177795258,
520542929,
10600249,
25300740,
21875589,
24085389,
59775105,
44190726,
1039649471,
1072607354,
# Perk combo seeds
839747651,
839844768,
840909713,
839959129,
840016192,
840039886,
840398606,
840439045,
840457463,
840492754,
840507802,
840513742,
840542079,
840574169,
840610974,
840626894,
840872436,
840894605,
841221188,
]
DEATH_PENALTY = 1
# All scoreable events: base 1 pt for every spell and perk, overrides below.
POINTS = {
# ── Spells ───────────────────────────────────────────────────────────────
**{sid: 1 for sid in ALL_SPELLS},
"ADD_TRIGGER": 10,
"NOLLA": 10,
"CHAOTIC_TRANSMUTATION": 10,
"DUPLICATE": 5,
"OMEGA": 10,
"HEALING_BOLT": 5,
# ── Perks ─────────────────────────────────────────────────────────────────
**{pid: 1 for pid in ALL_PERKS},
"EDIT_WANDS_EVERYWHERE": 10,
# ── Spell combos ─────────────────────────────────────────────────────────
"PING_PONG_DRILL": 10,
"HEAVY_SHOT_DISC": 10,
"TOUCH_OF_ANY": 5,
"TWO_ARC_MODIFIERS": 10,
"TWO_TRAIL_MODIFIERS": 10,
# ── Perk combos ──────────────────────────────────────────────────────────
"CRIMSON_ALCHEMIST": 15,
"GREEDY_GOBLIN_KING": 15,
"STORM_TOUCHED_ASCENDANT": 15,
"ARCHMAGE_OF_CONTROL": 15,
"HAUNTED_MAGE": 15,
"GLASS_CANNON_MESSIAH": 15,
"INFINITE_ENGINE": 15,
"PERFECT_ACCURACY_LOOP": 15,
"HOMING_DEATH_SWARM": 15,
"UNTOUCHABLE_FIELD": 15,
"ELECTRIC_SUSTAIN_LOOP": 15,
"IMMORTAL_LEECH_CORE": 15,
"PROJECTILE_OVERLOAD": 15,
"CLOSE_RANGE_DEATH_MACHINE": 15,
"CRITICAL_MASS": 15,
"HOLY_MOUNTAIN_ABUSER": 15,
"DEFLECTOR_MATRIX": 15,
"STORMBORNE_LEVITATOR": 15,
# ── Objectives ───────────────────────────────────────────────────────────
"HP_200": 5,
"HP_500": 5,
"HP_2000": 5,
"HP_5000": 5,
"GOLD_1000": 5,
"GOLD_10000": 5,
"GOLD_100000": 5,
"GOLD_1000000": 5,
**{f"ORB_{i}": 5 for i in range(34)},
"FIND_AMBROSIA": 10,
"WAND_MANA_500": 10,
"WAND_MANA_1000": 10,
"WAND_MANA_1500": 10,
"WAND_CAPACITY_10": 10,
"WAND_CAPACITY_20": 10,
"BOSS_KILL": 100,
"CRIMSON_ALCHEMIST-BOSS_KILL": 100,
"GREEDY_GOBLIN_KING-BOSS_KILL": 100,
"STORM_TOUCHED_ASCENDANT-BOSS_KILL": 100,
"ARCHMAGE_OF_CONTROL-BOSS_KILL": 100,
"HAUNTED_MAGE-BOSS_KILL": 100,
"GLASS_CANNON_MESSIAH-BOSS_KILL": 100,
"INFINITE_ENGINE-BOSS_KILL": 100,
"PERFECT_ACCURACY_LOOP-BOSS_KILL": 100,
"HOMING_DEATH_SWARM-BOSS_KILL": 100,
"UNTOUCHABLE_FIELD-BOSS_KILL": 100,
"ELECTRIC_SUSTAIN_LOOP-BOSS_KILL": 100,
"IMMORTAL_LEECH_CORE-BOSS_KILL": 100,
"PROJECTILE_OVERLOAD-BOSS_KILL": 100,
"CLOSE_RANGE_DEATH_MACHINE-BOSS_KILL": 100,
"CRITICAL_MASS-BOSS_KILL": 100,
"HOLY_MOUNTAIN_ABUSER-BOSS_KILL": 100,
"DEFLECTOR_MATRIX-BOSS_KILL": 100,
"STORMBORNE_LEVITATOR-BOSS_KILL": 100,
}
# Perk-combo IDs — used to award a per-combo boss-kill bonus.
PERK_COMBO_IDS = {
"CRIMSON_ALCHEMIST",
"GREEDY_GOBLIN_KING",
"STORM_TOUCHED_ASCENDANT",
"ARCHMAGE_OF_CONTROL",
"HAUNTED_MAGE",
"GLASS_CANNON_MESSIAH",
"INFINITE_ENGINE",
"PERFECT_ACCURACY_LOOP",
"HOMING_DEATH_SWARM",
"UNTOUCHABLE_FIELD",
"ELECTRIC_SUSTAIN_LOOP",
"IMMORTAL_LEECH_CORE",
"PROJECTILE_OVERLOAD",
"CLOSE_RANGE_DEATH_MACHINE",
"CRITICAL_MASS",
"HOLY_MOUNTAIN_ABUSER",
"DEFLECTOR_MATRIX",
"STORMBORNE_LEVITATOR",
}
BOSS_KILL_COMBO_BONUS = 100 # default bonus per active perk combo on boss kill
# IDs tried with the seed+timestamp scheme.
_ALL_IDS = list(POINTS.keys()) + ["DEATH"]
# Seed-less hashes (sha1(id) only, no seed, no timestamp).
_STATIC_LOOKUP = {
hashlib.sha1(k.encode()).hexdigest(): k
for k in ["-", "DEBUG", "polylan-mod", "cheatgui", "alchemy_recipes_display"]
}
def _sha1(text: str) -> str:
return hashlib.sha1(text.encode("utf-8")).hexdigest()
@cache
def resolve(
hash_val: str, ts: str, preferred_seed: int | None = None
) -> tuple[str | None, int | None]:
"""Return (name, seed) or (None, None). preferred_seed is tried first."""
if hash_val in _STATIC_LOOKUP:
return _STATIC_LOOKUP[hash_val], None
seeds = (
[preferred_seed] + [s for s in SEED_POOL if s != preferred_seed]
if preferred_seed is not None
else SEED_POOL
)
for seed in seeds:
prefix = f"{seed}|{ts}|"
for name in _ALL_IDS:
if _sha1(prefix + name) == hash_val:
return name, seed
return None, None
def parse_log(file) -> list:
entries = []
for il, line in enumerate(file.split("\n")):
m = re.match(r"\[(.+?)\] ([0-9a-f]{40})", line.rstrip())
if m:
entries.append({"ts": m.group(1), "hash": m.group(2)})
continue
print(f"Unable to parse line number {il:>4}: {line.strip()}??")
return entries
def decode(file) -> None:
entries = parse_log(file)
if not entries:
print("No entries found in log.")
return
seen = set()
total = 0
deaths = 0
have_init = False
have_debug = False
known_seed = None # cached once first seeded entry is resolved
current_run_perk_combos = set() # perk combos seen since the last "-" entry
for entry in entries:
name, seed = resolve(entry["hash"], entry["ts"], known_seed)
if name is None:
print(f" ???? (unresolved) {entry['hash']} ts: {entry['ts']}")
continue
if seed is not None and known_seed is None:
known_seed = seed
if name == "-":
have_init = True
current_run_perk_combos = set()
continue
if name == "DEBUG":
have_debug = True
continue
if name == "DEATH":
deaths += 1
continue
# skip mod-presence entries
if name not in POINTS:
continue
if name in seen:
continue
if name not in {"BOSS_KILL"}:
seen.add(name)
if name in PERK_COMBO_IDS:
current_run_perk_combos.add(name)
pts = POINTS.get(name, 0)
if pts:
print(f" +{pts:>4} {name:<40} first seen: {entry['ts']}")
total += pts
if name == "BOSS_KILL":
for combo_id in sorted(current_run_perk_combos):
bonus_key = f"{combo_id}-BOSS_KILL"
if bonus_key not in seen:
seen.add(bonus_key)
bonus_pts = POINTS.get(bonus_key, BOSS_KILL_COMBO_BONUS)
print(f" +{bonus_pts:>4} {bonus_key:<40} combo bonus")
total += bonus_pts
death_deduction = deaths * DEATH_PENALTY
if deaths:
print(
f" -{death_deduction:>4} ({deaths} death{'s' if deaths > 1 else ''} × {DEATH_PENALTY} pts)"
)
if have_init:
print(
f"\nTotal: {total - death_deduction} pts ({total} earned {death_deduction} penalty)"
)
if have_debug:
print("Note: DEBUG mode was active during at least one session.")

View File

@ -0,0 +1,40 @@
from noita.models import LogfileSubmission, Objectiv
from noita.services.decode import parse_log, resolve
from collections import Counter
def parse_objectives_from_logfile(logfile: LogfileSubmission) -> Counter:
"""Parse a log file, and output a count for each ID."""
file_data = logfile.file.read().decode()
ids = []
for entry in parse_log(file_data):
idx, _seed = resolve(entry["hash"], entry["ts"])
if idx:
ids.append(idx)
return Counter(ids)
def parse_objectives_and_store(logfile: LogfileSubmission) -> None:
"""Parse a logfile and store output."""
if not logfile.user:
return
counter = parse_objectives_from_logfile(logfile)
for idx, count in counter.items():
if idx in {"-", "DEBUG"}:
continue
obj, created = Objectiv.objects.get_or_create(
objectiv_id=idx,
user=logfile.user,
)
obj.count += count
obj.save(update_fields=["count"])

View File

@ -0,0 +1,535 @@
ALL_SPELLS = [
"FUNKY_SPELL",
"ACIDSHOT",
"BLACK_HOLE",
"BLACK_HOLE_DEATH_TRIGGER",
"BOMB",
"BOMB_CART",
"BUBBLESHOT",
"BUBBLESHOT_TRIGGER",
"AIR_BULLET",
"CHAIN_BOLT",
"CHAINSAW",
"CURSED_ORB",
"ANTIHEAL",
"DEATH_CROSS",
"DEATH_CROSS_BIG",
"LASER_EMITTER_FOUR",
"POWERDIGGER",
"DIGGER",
"PIPE_BOMB",
"PIPE_BOMB_DEATH_TRIGGER",
"GRENADE_LARGE",
"DYNAMITE",
"CRUMBLING_EARTH",
"TENTACLE_PORTAL",
"SLOW_BULLET",
"SLOW_BULLET_TRIGGER",
"SLOW_BULLET_TIMER",
"EXPANDING_ORB",
"FIREBALL",
"GRENADE",
"GRENADE_TRIGGER",
"GRENADE_TIER_2",
"GRENADE_TIER_3",
"GRENADE_ANTI",
"FIREBOMB",
"FIREWORK",
"FLAMETHROWER",
"GLITTER_BOMB",
"LANCE",
"GLUE_SHOT",
"HEAL_BULLET",
"LANCE_HOLY",
"BOMB_HOLY",
"BOMB_HOLY_GIGA",
"HOOK",
"ICEBALL",
"LASER",
"LIGHTNING",
"THUNDERBALL",
"BALL_LIGHTNING",
"LUMINOUS_DRILL",
"LASER_LUMINOUS_DRILL",
"BULLET",
"BULLET_TRIGGER",
"BULLET_TIMER",
"HEAVY_BULLET",
"HEAVY_BULLET_TRIGGER",
"HEAVY_BULLET_TIMER",
"MAGIC_SHIELD",
"BIG_MAGIC_SHIELD",
"ROCKET",
"ROCKET_TIER_2",
"ROCKET_TIER_3",
"METEOR",
"MIST_BLOOD",
"MIST_ALCOHOL",
"MIST_SLIME",
"MIST_RADIOACTIVE",
"BUCKSHOT",
"MEGALASER",
"EXPLODING_DUCKS",
"FREEZING_GAZE",
"INFESTATION",
"NUKE",
"NUKE_GIGA",
"DARKFLAME",
"GLOWING_BOLT",
"LASER_EMITTER",
"LASER_EMITTER_CUTTER",
"POLLEN",
"SPORE_POD",
"PROPANE_TANK",
"RANDOM_PROJECTILE",
"SUMMON_ROCK",
"DISC_BULLET",
"DISC_BULLET_BIG",
"DISC_BULLET_BIGGER",
"SLIMEBALL",
"LIGHT_BULLET",
"LIGHT_BULLET_TRIGGER",
"LIGHT_BULLET_TRIGGER_2",
"LIGHT_BULLET_TIMER",
"RUBBER_BALL",
"ARROW",
"BOUNCY_ORB",
"BOUNCY_ORB_TIMER",
"SPIRAL_SHOT",
"SPITTER",
"SPITTER_TIMER",
"SPITTER_TIER_2",
"SPITTER_TIER_2_TIMER",
"SPITTER_TIER_3",
"SPITTER_TIER_3_TIMER",
"EXPLODING_DEER",
"SUMMON_EGG",
"TNTBOX",
"TNTBOX_BIG",
"FISH",
"SUMMON_HOLLOW_EGG",
"MISSILE",
"PEBBLE",
"TENTACLE",
"TENTACLE_TIMER",
"TELEPORT_PROJECTILE_CLOSER",
"TELEPORT_PROJECTILE_STATIC",
"SWAPPER_PROJECTILE",
"TELEPORT_PROJECTILE",
"TELEPORT_PROJECTILE_SHORT",
"MINE",
"MINE_DEATH_TRIGGER",
"WHITE_HOLE",
"WORM_SHOT",
"WALL_HORIZONTAL",
"WALL_VERTICAL",
"WALL_SQUARE",
"REGENERATION_FIELD",
"FREEZE_FIELD",
"LEVITATION_FIELD",
"TELEPORTATION_FIELD",
"BERSERK_FIELD",
"SHIELD_FIELD",
"ELECTROCUTION_FIELD",
"POLYMORPH_FIELD",
"CHAOS_POLYMORPH_FIELD",
"CLOUD_WATER",
"CLOUD_OIL",
"CLOUD_BLOOD",
"CLOUD_ACID",
"CLOUD_THUNDER",
"DESTRUCTION",
"BOMB_DETONATOR",
"PURPLE_EXPLOSION_FIELD",
"WORM_RAIN",
"METEOR_RAIN",
"SWARM_FLY",
"SWARM_FIREBUG",
"SWARM_WASP",
"DELAYED_SPELL",
"MASS_POLYMORPH",
"PROJECTILE_THUNDER_FIELD",
"PROJECTILE_GRAVITY_FIELD",
"PROJECTILE_TRANSMUTATION_FIELD",
"RANDOM_STATIC_PROJECTILE",
"WHITE_HOLE_BIG",
"BLACK_HOLE_BIG",
"BLACK_HOLE_GIGA",
"THUNDER_BLAST",
"FIRE_BLAST",
"EXPLOSION_LIGHT",
"POISON_BLAST",
"EXPLOSION",
"ALCOHOL_BLAST",
"WHITE_HOLE_GIGA",
"FRIEND_FLY",
"VACUUM_POWDER",
"VACUUM_LIQUID",
"VACUUM_ENTITIES",
"CLUSTERMOD",
"MANA_REDUCE",
"ARC_POISON",
"ARC_FIRE",
"ARC_GUNPOWDER",
"ARC_ELECTRIC",
"ROCKET_DOWNWARDS",
"ROCKET_OCTAGON",
"BOUNCE",
"BOUNCE_SPARK",
"BOUNCE_LASER",
"REMOVE_BOUNCE",
"BOUNCE_SMALL_EXPLOSION",
"BOUNCE_HOLE",
"BOUNCE_EXPLOSION",
"BOUNCE_LARPA",
"BOUNCE_LIGHTNING",
"BOUNCE_LASER_EMITTER",
"EXPLOSION_TINY",
"HITFX_CRITICAL_BLOOD",
"HITFX_CRITICAL_OIL",
"HITFX_CRITICAL_WATER",
"HITFX_BURNING_CRITICAL_HIT",
"CRITICAL_HIT",
"AREA_DAMAGE",
"BLOODLUST",
"DAMAGE",
"DAMAGE_RANDOM",
"DAMAGE_FOREVER",
"ZERO_DAMAGE",
"HEAVY_SHOT",
"LIGHT_SHOT",
"CRUMBLING_EARTH_PROJECTILE",
"FREEZE",
"ELECTRIC_CHARGE",
"HITFX_EXPLOSION_ALCOHOL",
"HITFX_EXPLOSION_ALCOHOL_GIGA",
"HITFX_EXPLOSION_SLIME",
"HITFX_EXPLOSION_SLIME_GIGA",
"HITFX_TOXIC_CHARM",
"COLOUR_RED",
"COLOUR_ORANGE",
"COLOUR_YELLOW",
"COLOUR_GREEN",
"COLOUR_BLUE",
"COLOUR_PURPLE",
"COLOUR_RAINBOW",
"COLOUR_INVIS",
"HEAVY_SPREAD",
"KNOCKBACK",
"LARPA_CHAOS_2",
"LARPA_CHAOS",
"LARPA_DOWNWARDS",
"LARPA_DEATH",
"LARPA_UPWARDS",
"LIFETIME_DOWN",
"LIFETIME",
"CHAIN_SHOT",
"NOLLA",
"LIGHT",
"NECROMANCY",
"ORBIT_DISCS",
"ORBIT_NUKES",
"ORBIT_FIREBALLS",
"ORBIT_LARPA",
"ORBIT_LASERS",
"LINE_ARC",
"HORIZONTAL_ARC",
"GRAVITY",
"GRAVITY_ANTI",
"FLY_UPWARDS",
"FLY_DOWNWARDS",
"ORBIT_SHOT",
"TRUE_ORBIT",
"SPIRALING_SHOT",
"PINGPONG_PATH",
"PHASING_ARC",
"CHAOTIC_ARC",
"SINEWAVE",
"HOMING_WAND",
"HOMING_CURSOR",
"HOMING_SHORT",
"AUTOAIM",
"HOMING",
"HOMING_ROTATE",
"HOMING_SHOOTER",
"ANTI_HOMING",
"HOMING_ACCELERATING",
"HOMING_AREA",
"FIREBALL_RAY_ENEMY",
"GRAVITY_FIELD_ENEMY",
"TENTACLE_RAY_ENEMY",
"LIGHTNING_RAY_ENEMY",
"HITFX_PETRIFY",
"PIERCING_SHOT",
"LASER_EMITTER_WIDER",
"QUANTUM_SPLIT",
"RANDOM_EXPLOSION",
"RANDOM_MODIFIER",
"RECOIL",
"RECOIL_DAMPER",
"RECHARGE",
"SPREAD_REDUCE",
"EXPLOSION_REMOVE",
"SLOW_BUT_STEADY",
"ENERGY_SHIELD_SHOT",
"SPEED",
"DECELERATING_SHOT",
"ACCELERATING_SHOT",
"FIZZLE",
"FLOATING_ARC",
"AVOIDING_ARC",
"CLIPPING_SHOT",
"UNSTABLE_GUNPOWDER",
"MATTER_EATER",
"EXPLOSIVE_PROJECTILE",
"FIREBALL_RAY_LINE",
"FIREBALL_RAY",
"LIGHTNING_RAY",
"TENTACLE_RAY",
"LASER_EMITTER_RAY",
"SPELLS_TO_POWER",
"ESSENCE_TO_POWER",
"ACID_TRAIL",
"FIRE_TRAIL",
"GUNPOWDER_TRAIL",
"OIL_TRAIL",
"POISON_TRAIL",
"RAINBOW_TRAIL",
"WATER_TRAIL",
"BURN_TRAIL",
"WATER_TO_POISON",
"BLOOD_TO_ACID",
"LAVA_TO_BLOOD",
"LIQUID_TO_EXPLOSION",
"TOXIC_TO_ACID",
"STATIC_TO_SAND",
"TRANSMUTATION",
"CURSE_WITHER_ELECTRICITY",
"CURSE_WITHER_EXPLOSION",
"CURSE_WITHER_MELEE",
"CURSE_WITHER_PROJECTILE",
"CURSE",
"BURST_2",
"BURST_3",
"BURST_4",
"BURST_8",
"BURST_X",
"SCATTER_2",
"SCATTER_3",
"SCATTER_4",
"I_SHAPE",
"T_SHAPE",
"PENTAGRAM_SHAPE",
"CIRCLE_SHAPE",
"Y_SHAPE",
"W_SHAPE",
"TOUCH_PISS",
"TOUCH_GRASS",
"SOILBALL",
"CIRCLE_FIRE",
"CIRCLE_ACID",
"CIRCLE_OIL",
"CIRCLE_WATER",
"MATERIAL_BLOOD",
"MATERIAL_CEMENT",
"MATERIAL_OIL",
"MATERIAL_ACID",
"MATERIAL_WATER",
"SEA_LAVA",
"SEA_ALCOHOL",
"SEA_OIL",
"SEA_WATER",
"SEA_ACID",
"SEA_ACID_GAS",
"SEA_SWAMP",
"SEA_MIMIC",
"TOUCH_BLOOD",
"TOUCH_GOLD",
"TOUCH_OIL",
"TOUCH_SMOKE",
"TOUCH_ALCOHOL",
"TOUCH_WATER",
"X_RAY",
"BLOOD_MAGIC",
"CASTER_CAST",
"I_SHOT",
"Y_SHOT",
"T_SHOT",
"W_SHOT",
"QUAD_SHOT",
"PENTA_SHOT",
"HEXA_SHOT",
"SUPER_TELEPORT_CAST",
"TELEPORT_CAST",
"LONG_DISTANCE_CAST",
"ALL_ACID",
"ALL_BLACKHOLES",
"ALL_DEATHCROSSES",
"ALL_ROCKETS",
"ALL_NUKES",
"ALL_DISCS",
"SUMMON_WANDGHOST",
"TEMPORARY_PLATFORM",
"TEMPORARY_WALL",
"MONEY_MAGIC",
"BLOOD_TO_POWER",
"RESET",
"ENERGY_SHIELD",
"ENERGY_SHIELD_SECTOR",
"TINY_GHOST",
"TORCH",
"TORCH_ELECTRIC",
"ADD_TRIGGER",
"ADD_TIMER",
"ADD_DEATH_TRIGGER",
"CESSATION",
"DIVIDE_2",
"DIVIDE_3",
"DIVIDE_4",
"DIVIDE_10",
"OMEGA",
"ZETA",
"TAU",
"SIGMA",
"PHI",
"MU",
"ALPHA",
"GAMMA",
"KANTELE_A",
"KANTELE_D",
"KANTELE_DIS",
"KANTELE_E",
"KANTELE_G",
"OCARINA_A",
"OCARINA_B",
"OCARINA_C",
"OCARINA_D",
"OCARINA_E",
"OCARINA_F",
"OCARINA_GSHARP",
"OCARINA_A2",
"RANDOM_SPELL",
"DRAW_RANDOM",
"DRAW_RANDOM_X3",
"DRAW_3_RANDOM",
"IF_PROJECTILE",
"IF_HP",
"IF_ENEMY",
"IF_HALF",
"IF_ELSE",
"IF_END",
"DUPLICATE",
"SUMMON_PORTAL",
"ALL_SPELLS",
]
ALL_PERKS = [
"CRITICAL_HIT",
"BREATH_UNDERWATER",
"EXTRA_MONEY",
"EXTRA_MONEY_TRICK_KILL",
"GOLD_IS_FOREVER",
"TRICK_BLOOD_MONEY",
"EXPLODING_GOLD",
"HOVER_BOOST",
"FASTER_LEVITATION",
"MOVEMENT_FASTER",
"LOW_GRAVITY",
"HIGH_GRAVITY",
"SPEED_DIVER",
"STRONG_KICK",
"TELEKINESIS",
"REPELLING_CAPE",
"EXPLODING_CORPSES",
"SAVING_GRACE",
"INVISIBILITY",
"GLOBAL_GORE",
"REMOVE_FOG_OF_WAR",
"LEVITATION_TRAIL",
"VAMPIRISM",
"EXTRA_HP",
"HEARTS_MORE_EXTRA_HP",
"GLASS_CANNON",
"LOW_HP_DAMAGE_BOOST",
"RESPAWN",
"WORM_ATTRACTOR",
"WORM_DETRACTOR",
"RADAR_ENEMY",
"FOOD_CLOCK",
"WAND_RADAR",
"ITEM_RADAR",
"MOON_RADAR",
"PROTECTION_FIRE",
"PROTECTION_RADIOACTIVITY",
"PROTECTION_EXPLOSION",
"PROTECTION_MELEE",
"PROTECTION_ELECTRICITY",
"TELEPORTITIS",
"TELEPORTITIS_DODGE",
"STAINLESS_ARMOUR",
"EDIT_WANDS_EVERYWHERE",
"NO_WAND_EDITING",
"WAND_EXPERIMENTER",
"ADVENTURER",
"ABILITY_ACTIONS_MATERIALIZED",
"PROJECTILE_HOMING",
"PROJECTILE_HOMING_SHOOTER",
"UNLIMITED_SPELLS",
"FREEZE_FIELD",
"FIRE_GAS",
"DISSOLVE_POWDERS",
"BLEED_SLIME",
"BLEED_OIL",
"BLEED_GAS",
"SHIELD",
"REVENGE_EXPLOSION",
"REVENGE_TENTACLE",
"REVENGE_RATS",
"REVENGE_BULLET",
"ATTACK_FOOT",
"LEGGY_FEET",
"PLAGUE_RATS",
"VOMIT_RATS",
"CORDYCEPS",
"MOLD",
"WORM_SMALLER_HOLES",
"PROJECTILE_REPULSION",
"RISKY_CRITICAL",
"FUNGAL_DISEASE",
"PROJECTILE_SLOW_FIELD",
"PROJECTILE_REPULSION_SECTOR",
"PROJECTILE_EATER_SECTOR",
"ORBIT",
"ANGRY_GHOST",
"HUNGRY_GHOST",
"DEATH_GHOST",
"HOMUNCULUS",
"ELECTRICITY",
"ATTRACT_ITEMS",
"EXTRA_KNOCKBACK",
"LOWER_SPREAD",
"LOW_RECOIL",
"BOUNCE",
"FAST_PROJECTILES",
"ALWAYS_CAST",
"EXTRA_MANA",
"NO_MORE_SHUFFLE",
"NO_MORE_KNOCKBACK",
"DUPLICATE_PROJECTILE",
"FASTER_WANDS",
"EXTRA_SLOTS",
"CONTACT_DAMAGE",
"EXTRA_PERK",
"PERKS_LOTTERY",
"GAMBLE",
"EXTRA_SHOP_ITEM",
"GENOME_MORE_HATRED",
"GENOME_MORE_LOVE",
"PEACE_WITH_GODS",
"MANA_FROM_KILLS",
"ANGRY_LEVITATION",
"LASER_AIM",
"PERSONAL_LASER",
"MEGA_BEAM_STONE",
"IRON_STOMACH",
]

View File

@ -6,12 +6,14 @@ from noita.api import router as noita_router
# Create the main API instance
api = NinjaAPI(
title="Opus Magnum Submission API",
title="PolyLAN Submission API",
version="1.0.0",
description="""API for managing Opus Magnum puzzle submissions.
description="""API for managing Opus Magnum puzzle submissions, and Noita runs.
The Opus Magnum Submission API allows clients to upload, manage, validate, and review puzzle solution submissions for the Opus Magnum puzzle game community.
It provides features for user authentication, puzzle listing, submission uploads, automated and manual OCR validation, and administrative workflows.
The Noita Submission API allows clients to upload the result of the log file of the PolyLAN noita mod. It parses the output, and store each objectiv made by the user.
""",
openapi_extra={
"info": {

View File

@ -1,5 +1,10 @@
<script setup lang="ts">
import { ref, computed } from "vue";
import { ref, onMounted } from "vue";
interface Objective {
objectiv_id: string;
count: number;
}
const userInfo = ref({
username: "Player",
@ -11,6 +16,8 @@ const userInfo = ref({
const uploadedFiles = ref<File[]>([]);
const isUploading = ref(false);
const isDragover = ref(false);
const objectives = ref<Objective[]>([]);
const isLoadingObjectives = ref(false);
const handleFileUpload = (event: Event) => {
const input = event.target as HTMLInputElement;
@ -78,6 +85,24 @@ const submitRun = async () => {
const goHome = () => {
window.location.href = "/";
};
const fetchObjectives = async () => {
isLoadingObjectives.value = true;
try {
const response = await fetch("/api/noita/objectives");
if (!response.ok) throw new Error("Failed to fetch objectives");
objectives.value = await response.json();
} catch (error) {
console.error("Error fetching objectives:", error);
} finally {
isLoadingObjectives.value = false;
}
};
onMounted(() => {
fetchObjectives();
});
</script>
<template>
@ -149,25 +174,15 @@ const goHome = () => {
</h2>
<!-- Upload Area -->
<div
@dragover="handleDragover"
@dragleave="handleDragleave"
@drop="handleDrop"
:class="[
'border-2 border-dashed rounded-lg p-8 text-center transition-colors cursor-pointer bg-base-200/50 mb-6',
isDragover ? 'border-primary bg-primary/10' : 'border-base-300 hover:border-primary'
]"
>
<input
type="file"
multiple
@change="handleFileUpload"
class="hidden"
id="file-upload"
accept="video/*,image/*"
/>
<div @dragover="handleDragover" @dragleave="handleDragleave" @drop="handleDrop" :class="[
'border-2 border-dashed rounded-lg p-8 text-center transition-colors cursor-pointer bg-base-200/50 mb-6',
isDragover ? 'border-primary bg-primary/10' : 'border-base-300 hover:border-primary'
]">
<input type="file" multiple @change="handleFileUpload" class="hidden" id="file-upload"
accept="video/*,image/*" />
<label for="file-upload" class="cursor-pointer flex flex-col items-center gap-3">
<i :class="['mdi text-4xl', isDragover ? 'mdi-cloud-check text-primary' : 'mdi-file-upload text-base-content/50']"></i>
<i
:class="['mdi text-4xl', isDragover ? 'mdi-cloud-check text-primary' : 'mdi-file-upload text-base-content/50']"></i>
<div>
<p class="font-semibold">Click to upload or drag and drop</p>
<p class="text-sm text-base-content/70">Video or image files (MP4, PNG, etc.)</p>
@ -179,16 +194,14 @@ const goHome = () => {
<div v-if="uploadedFiles.length > 0" class="mb-6">
<p class="font-semibold mb-3">Selected Files:</p>
<div class="space-y-2">
<div v-for="(file, index) in uploadedFiles" :key="index" class="flex items-center gap-3 bg-base-200 p-3 rounded-lg">
<div v-for="(file, index) in uploadedFiles" :key="index"
class="flex items-center gap-3 bg-base-200 p-3 rounded-lg">
<i class="mdi mdi-file text-primary"></i>
<div class="flex-1 min-w-0">
<p class="font-medium truncate">{{ file.name }}</p>
<p class="text-xs text-base-content/70">{{ (file.size / 1024 / 1024).toFixed(2) }} MB</p>
</div>
<button
@click="uploadedFiles.splice(index, 1)"
class="btn btn-ghost btn-xs"
>
<button @click="uploadedFiles.splice(index, 1)" class="btn btn-ghost btn-xs">
<i class="mdi mdi-close"></i>
</button>
</div>
@ -201,11 +214,8 @@ const goHome = () => {
<i class="mdi mdi-folder-open mr-2"></i>
Choose Files
</label>
<button
@click="submitRun"
:disabled="uploadedFiles.length === 0 || isUploading"
:class="['btn btn-primary flex-1', { 'loading': isUploading }]"
>
<button @click="submitRun" :disabled="uploadedFiles.length === 0 || isUploading"
:class="['btn btn-primary flex-1', { 'loading': isUploading }]">
<i v-if="!isUploading" class="mdi mdi-send mr-2"></i>
{{ isUploading ? 'Submitting...' : 'Submit Run' }}
</button>
@ -216,6 +226,44 @@ const goHome = () => {
</p>
</div>
</div>
<!-- Objectives Table -->
<div class="card bg-base-100 shadow-lg mt-8">
<div class="card-body">
<h2 class="card-title text-2xl mb-6">
<i class="mdi mdi-view-list text-purple-500 mr-2"></i>
Your Objectives
</h2>
<div v-if="isLoadingObjectives" class="flex justify-center py-8">
<span class="loading loading-spinner loading-lg"></span>
</div>
<div v-else-if="objectives.length === 0" class="text-center py-8">
<p class="text-base-content/70 mb-2">No objectives completed yet</p>
<p class="text-sm text-base-content/50">Submit your runs to unlock objectives!</p>
</div>
<div v-else class="overflow-x-auto">
<table class="table table-zebra w-full">
<thead>
<tr>
<th>Objective ID</th>
<th class="text-right">Count</th>
</tr>
</thead>
<tbody>
<tr v-for="obj in objectives" :key="obj.objectiv_id">
<td class="font-medium">{{ obj.objectiv_id }}</td>
<td class="text-right">
<span class="badge badge-primary badge-lg">{{ obj.count }}</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>