Skip to content

Commit 3c7d4b2

Browse files
authored
Merge pull request #727 from Path-of-Modifiers/27-add-support-for-finding-unidentified-uniques
27 add support for finding unidentified uniques
2 parents 5c22841 + 083e8da commit 3c7d4b2

File tree

26 files changed

+1400
-71
lines changed

26 files changed

+1400
-71
lines changed

src/.env

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ SMTP_PASSWORD=changethis
3636
TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA
3737

3838
# League
39-
CURRENT_SOFTCORE_LEAGUE="Phrecia"
39+
CURRENT_SOFTCORE_LEAGUE="Standard"
4040
CURRENT_HARDCORE_LEAGUE="Hardcore ${CURRENT_SOFTCORE_LEAGUE}"
4141

4242
LEAGUE_LAUNCH_TIME=2024-07-26T19:00:00Z # ISO 8601 format. Round backwards to whole hour number

src/backend_api/app/alembic/env.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@ def get_url():
3636
# ... etc.
3737

3838

39+
def include_object(_, name: str | None, type_, *args, **kwargs) -> bool:
40+
return not (type_ == "table" and name.startswith("_"))
41+
42+
3943
def run_migrations_offline() -> None:
4044
"""Run migrations in 'offline' mode.
4145
@@ -54,7 +58,8 @@ def run_migrations_offline() -> None:
5458
target_metadata=target_metadata,
5559
literal_binds=True,
5660
dialect_opts={"paramstyle": "named"},
57-
compare_type=False
61+
compare_type=False,
62+
include_object=include_object,
5863
)
5964

6065
with context.begin_transaction():
@@ -77,7 +82,9 @@ def run_migrations_online() -> None:
7782
)
7883

7984
with connectable.connect() as connection:
80-
context.configure(connection=connection, target_metadata=target_metadata, compare_type=False)
85+
context.configure(
86+
connection=connection, target_metadata=target_metadata, compare_type=False
87+
)
8188

8289
with context.begin_transaction():
8390
context.run_migrations()
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
from typing import Generic, TypeVar
2+
from alembic.operations import Operations, MigrateOperation
3+
4+
# From Alembic cookbook, with personal additions for typing:
5+
# https://alembic.sqlalchemy.org/en/latest/cookbook.html#replaceable-objects
6+
7+
8+
class ReplaceableObject:
9+
def __init__(self, name, sqltext):
10+
self.name = name
11+
self.sqltext = sqltext
12+
13+
14+
class ReplaceableTrigger(ReplaceableObject):
15+
def __init__(self, name, table, function, trigger):
16+
self.name = name
17+
self.table = table
18+
self.function = function
19+
self.trigger = trigger
20+
21+
22+
ObjectType = TypeVar("ObjectType", bound=ReplaceableObject)
23+
24+
25+
class ReversibleOp(MigrateOperation, Generic[ObjectType]):
26+
def __init__(self, target: ObjectType):
27+
self.target = target
28+
29+
@classmethod
30+
def invoke_for_target(cls, operations: Operations, target: ObjectType):
31+
op = cls(target)
32+
return operations.invoke(op)
33+
34+
def reverse(self):
35+
raise NotImplementedError()
36+
37+
@classmethod
38+
def _get_object_from_version(cls, operations: Operations, ident):
39+
version, objname = ident.split(".")
40+
41+
module = operations.get_context().script.get_revision(version).module
42+
obj = getattr(module, objname)
43+
return obj
44+
45+
@classmethod
46+
def replace(cls, operations: Operations, target, replaces=None, replace_with=None):
47+
48+
if replaces:
49+
old_obj = cls._get_object_from_version(operations, replaces)
50+
drop_old = cls(old_obj).reverse()
51+
create_new = cls(target)
52+
elif replace_with:
53+
old_obj = cls._get_object_from_version(operations, replace_with)
54+
drop_old = cls(target).reverse()
55+
create_new = cls(old_obj)
56+
else:
57+
raise TypeError("replaces or replace_with is required")
58+
59+
operations.invoke(drop_old)
60+
operations.invoke(create_new)
61+
62+
63+
@Operations.register_operation("create_view", "invoke_for_target")
64+
@Operations.register_operation("replace_view", "replace")
65+
class CreateViewOp(ReversibleOp[ReplaceableObject]):
66+
def reverse(self):
67+
return DropViewOp(self.target)
68+
69+
70+
@Operations.register_operation("drop_view", "invoke_for_target")
71+
class DropViewOp(ReversibleOp[ReplaceableObject]):
72+
def reverse(self):
73+
return CreateViewOp(self.target)
74+
75+
76+
@Operations.register_operation("create_trigger", "invoke_for_target")
77+
@Operations.register_operation("replace_trigger", "replace")
78+
class CreateTriggerOp(ReversibleOp[ReplaceableTrigger]):
79+
def reverse(self):
80+
return DropTriggerOp(self.target)
81+
82+
83+
@Operations.register_operation("drop_trigger", "invoke_for_target")
84+
class DropTriggerOp(ReversibleOp[ReplaceableTrigger]):
85+
def reverse(self):
86+
return CreateTriggerOp(self.target)
87+
88+
89+
@Operations.implementation_for(CreateViewOp)
90+
def create_view(operations: Operations, operation: CreateViewOp):
91+
operations.execute(
92+
"CREATE VIEW %s AS %s" % (operation.target.name, operation.target.sqltext)
93+
)
94+
95+
96+
@Operations.implementation_for(DropViewOp)
97+
def drop_view(operations: Operations, operation: DropViewOp):
98+
operations.execute("DROP VIEW %s" % operation.target.name)
99+
100+
101+
@Operations.implementation_for(CreateTriggerOp)
102+
def create_trigger(operations: Operations, operation: CreateTriggerOp):
103+
operations.execute(
104+
"CREATE FUNCTION %s() %s" % (operation.target.name, operation.target.function)
105+
)
106+
operations.execute(
107+
"CREATE TRIGGER %s %s" % (operation.target.name, operation.target.trigger)
108+
)
109+
110+
111+
@Operations.implementation_for(DropTriggerOp)
112+
def drop_trigger(operations: Operations, operation: DropTriggerOp):
113+
operations.execute(
114+
"DROP TRIGGER {} ON {};".format(operation.target.name, operation.target.table)
115+
)
116+
operations.execute("DROP FUNCTION %s();" % operation.target.name)
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
"""Added Unidentified Item Table
2+
3+
Revision ID: 0f3f15f56b7d
4+
Revises: fa3b02812f53
5+
Create Date: 2025-03-04 00:21:22.039605
6+
7+
"""
8+
9+
from typing import Sequence, Union
10+
11+
from alembic import op
12+
import sqlalchemy as sa
13+
from sqlalchemy.dialects import postgresql
14+
15+
# revision identifiers, used by Alembic.
16+
revision: str = "0f3f15f56b7d"
17+
down_revision: Union[str, None] = "fa3b02812f53"
18+
branch_labels: Union[str, Sequence[str], None] = None
19+
depends_on: Union[str, Sequence[str], None] = None
20+
21+
22+
def upgrade() -> None:
23+
# ### commands auto generated by Alembic - please adjust! ###
24+
op.create_table(
25+
"unidentified_item",
26+
sa.Column("name", sa.Text(), nullable=False),
27+
sa.Column("itemBaseTypeId", sa.SmallInteger(), nullable=False),
28+
sa.Column("createdHoursSinceLaunch", sa.SmallInteger(), nullable=False),
29+
sa.Column("league", sa.Text(), nullable=False),
30+
sa.Column(
31+
"itemId",
32+
sa.BigInteger(),
33+
sa.Identity(always=True, start=1, increment=1),
34+
nullable=False,
35+
),
36+
sa.Column("currencyId", sa.Integer(), nullable=False),
37+
sa.Column("ilvl", sa.SmallInteger(), nullable=False),
38+
sa.Column("currencyAmount", sa.Float(precision=4), nullable=False),
39+
sa.Column("nItems", sa.SmallInteger(), nullable=False, default=1),
40+
sa.Column("identified", sa.Boolean(), nullable=False),
41+
sa.Column("rarity", sa.Text(), nullable=False),
42+
sa.Column("aggregated", sa.Boolean(), nullable=False, default=False),
43+
sa.CheckConstraint(
44+
"\n identified IS NOT TRUE\n ",
45+
name="identified_is_false",
46+
),
47+
sa.ForeignKeyConstraint(
48+
["currencyId"], ["currency.currencyId"], ondelete="RESTRICT"
49+
),
50+
sa.ForeignKeyConstraint(
51+
["itemBaseTypeId"],
52+
["item_base_type.itemBaseTypeId"],
53+
onupdate="CASCADE",
54+
ondelete="RESTRICT",
55+
),
56+
sa.PrimaryKeyConstraint("itemId"),
57+
)
58+
op.create_index(
59+
"ix_unid_item_name_itemBaseTypeId_createdHoursSinceLaunch_league",
60+
"unidentified_item",
61+
["name", "itemBaseTypeId", "createdHoursSinceLaunch", "league"],
62+
unique=False,
63+
)
64+
op.create_index(
65+
op.f("ix_unidentified_item_currencyId"),
66+
"unidentified_item",
67+
["currencyId"],
68+
unique=False,
69+
)
70+
op.drop_index("item_createdHoursSinceLaunch_idx", table_name="item")
71+
op.create_foreign_key(
72+
None,
73+
"item",
74+
"item_base_type",
75+
["itemBaseTypeId"],
76+
["itemBaseTypeId"],
77+
onupdate="CASCADE",
78+
ondelete="RESTRICT",
79+
)
80+
op.create_foreign_key(
81+
None, "item", "currency", ["currencyId"], ["currencyId"], ondelete="RESTRICT"
82+
)
83+
op.drop_index(
84+
"item_modifier_createdHoursSinceLaunch_idx", table_name="item_modifier"
85+
)
86+
op.create_foreign_key(
87+
None,
88+
"item_modifier",
89+
"modifier",
90+
["modifierId"],
91+
["modifierId"],
92+
onupdate="CASCADE",
93+
ondelete="CASCADE",
94+
)
95+
# ### end Alembic commands ###
96+
97+
98+
def downgrade() -> None:
99+
# ### commands auto generated by Alembic - please adjust! ###
100+
op.drop_constraint(None, "item_modifier", type_="foreignkey")
101+
op.create_index(
102+
"item_modifier_createdHoursSinceLaunch_idx",
103+
"item_modifier",
104+
[sa.text('"createdHoursSinceLaunch" DESC')],
105+
unique=False,
106+
)
107+
op.drop_constraint(None, "item", type_="foreignkey")
108+
op.drop_constraint(None, "item", type_="foreignkey")
109+
op.create_index(
110+
"item_createdHoursSinceLaunch_idx",
111+
"item",
112+
[sa.text('"createdHoursSinceLaunch" DESC')],
113+
unique=False,
114+
)
115+
op.drop_index(
116+
op.f("ix_unidentified_item_currencyId"), table_name="unidentified_item"
117+
)
118+
op.drop_index(
119+
"ix_unid_item_name_itemBaseTypeId_createdHoursSinceLaunch_league",
120+
table_name="unidentified_item",
121+
)
122+
op.drop_table("unidentified_item")
123+
# ### end Alembic commands ###
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
"""Added unidentified aggregation job
2+
3+
Revision ID: e38727349f3f
4+
Revises: 0f3f15f56b7d
5+
Create Date: 2025-03-31 14:15:02.926141
6+
7+
"""
8+
9+
from typing import Sequence, Union
10+
11+
from alembic import op
12+
import sqlalchemy as sa
13+
14+
from app.alembic.replaceable_objects.main import ReplaceableTrigger
15+
16+
17+
# revision identifiers, used by Alembic.
18+
revision: str = "e38727349f3f"
19+
down_revision: Union[str, None] = "0f3f15f56b7d"
20+
branch_labels: Union[str, Sequence[str], None] = None
21+
depends_on: Union[str, Sequence[str], None] = None
22+
23+
24+
unidentified_aggregation_trigger = ReplaceableTrigger(
25+
"aggregate_unidentified",
26+
"unidentified_item",
27+
"""
28+
RETURNS TRIGGER AS $aggregate_unidentified$
29+
DECLARE
30+
current_hour INT;
31+
divine_id INT;
32+
divine_value FLOAT;
33+
BEGIN
34+
IF EXISTS (SELECT 1 FROM unidentified_item AS unid WHERE unid."createdHoursSinceLaunch" = NEW."createdHoursSinceLaunch") THEN
35+
RETURN NEW;
36+
END IF;
37+
38+
current_hour := (SELECT MAX(unid."createdHoursSinceLaunch") FROM unidentified_item AS unid);
39+
divine_id := (SELECT MAX(cur."currencyId") FROM currency AS cur WHERE cur."tradeName"='divine');
40+
divine_value := (SELECT cur."valueInChaos" FROM currency AS cur WHERE cur."currencyId"=divine_id);
41+
42+
43+
WITH aggregates AS (
44+
SELECT name, unid."itemBaseTypeId", unid."createdHoursSinceLaunch", league, ilvl, stddev(unid."currencyAmount" * cur."valueInChaos") AS std, AVG(unid."currencyAmount" * cur."valueInChaos") AS calc_avg, COUNT(unid."itemId") AS calc_count
45+
FROM unidentified_item AS unid
46+
NATURAL JOIN currency as cur
47+
WHERE unid."createdHoursSinceLaunch" = current_hour
48+
AND NOT aggregated
49+
GROUP BY name, unid."itemBaseTypeId", unid."createdHoursSinceLaunch", league, ilvl
50+
), affected_item_ids AS (
51+
SELECT unid."itemId" FROM unidentified_item AS unid WHERE NOT aggregated AND unid."createdHoursSinceLaunch"=current_hour
52+
)
53+
54+
INSERT INTO unidentified_item (name, "itemBaseTypeId", "createdHoursSinceLaunch", league, "currencyId", ilvl, "currencyAmount", "nItems", identified, rarity, aggregated)
55+
SELECT name, unid."itemBaseTypeId", unid."createdHoursSinceLaunch", league, divine_id, ilvl, AVG(unid."currencyAmount" * cur."valueInChaos") / divine_value, calc_count, identified, rarity, TRUE
56+
FROM unidentified_item AS unid
57+
NATURAL JOIN aggregates
58+
NATURAL JOIN currency AS cur
59+
WHERE unid."currencyAmount" * cur."valueInChaos" <= calc_avg + 1.97 * std
60+
AND unid."currencyAmount" * cur."valueInChaos" >= calc_avg - 1.97 * std
61+
GROUP BY name, unid."itemBaseTypeId", unid."createdHoursSinceLaunch", league, ilvl, identified, rarity, calc_count;
62+
63+
DELETE FROM unidentified_item as unid
64+
WHERE NOT aggregated AND unid."createdHoursSinceLaunch"=current_hour;
65+
66+
RETURN NEW;
67+
END;
68+
$aggregate_unidentified$ LANGUAGE plpgsql;
69+
""",
70+
"""
71+
BEFORE INSERT ON unidentified_item
72+
FOR EACH ROW
73+
EXECUTE FUNCTION aggregate_unidentified();
74+
""",
75+
)
76+
77+
78+
def upgrade() -> None:
79+
op.create_trigger(unidentified_aggregation_trigger)
80+
81+
82+
def downgrade() -> None:
83+
op.drop_trigger(unidentified_aggregation_trigger)

src/backend_api/app/api/api.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
test_prefix,
2020
turnstile,
2121
turnstile_prefix,
22+
unidentified_item,
23+
unidentified_item_prefix,
2224
)
2325

2426
api_router = APIRouter()
@@ -40,6 +42,11 @@
4042
api_router.include_router(
4143
item.router, prefix=f"/{item_prefix}", tags=[f"{item_prefix}s"]
4244
)
45+
api_router.include_router(
46+
unidentified_item.router,
47+
prefix=f"/{unidentified_item_prefix}",
48+
tags=[f"{unidentified_item_prefix}s"],
49+
)
4350
api_router.include_router(
4451
modifier.router, prefix=f"/{modifier_prefix}", tags=[f"{modifier_prefix}s"]
4552
)

src/backend_api/app/api/routes/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from app.api.routes.item_base_type import item_base_type_prefix
33
from app.api.routes.item_modifier import item_modifier_prefix
44
from app.api.routes.item import item_prefix
5+
from app.api.routes.unidentified_item import unidentified_item_prefix
56
from app.api.routes.login import login_prefix
67
from app.api.routes.modifier import modifier_prefix
78
from app.api.routes.plot import plot_prefix

0 commit comments

Comments
 (0)