Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c0f2daccb1 | |||
| abdf4cc654 | |||
| 5449276521 | |||
| 787e2bbae9 | |||
| 421540c52b | |||
| 1e24aecad8 | |||
| c9fb28ab6d | |||
| c1e2c6d8b5 | |||
| f7b041f86f | |||
| 05bd70a9fe | |||
| bb5c7e6e21 | |||
| 14bec979e6 | |||
| 4b45483ac3 |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -7,8 +7,8 @@ android {
|
|||||||
applicationId "com.warren.iwanttoheal"
|
applicationId "com.warren.iwanttoheal"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 63
|
versionCode 76
|
||||||
versionName "1.0.43"
|
versionName "1.0.57"
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
aaptOptions {
|
aaptOptions {
|
||||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||||
|
|||||||
+89
-44
@@ -455,17 +455,17 @@ INSERT INTO encounter_loot (encounter_id, item_id, difficulty_id, drop_weight, d
|
|||||||
INSERT INTO encounter_loot (encounter_id, item_id, difficulty_id, drop_weight, drop_chance) VALUES (1403, 1683005, 5, 100, 1);
|
INSERT INTO encounter_loot (encounter_id, item_id, difficulty_id, drop_weight, drop_chance) VALUES (1403, 1683005, 5, 100, 1);
|
||||||
INSERT INTO encounter_loot (encounter_id, item_id, difficulty_id, drop_weight, drop_chance) VALUES (1503, 1783005, 5, 100, 1);
|
INSERT INTO encounter_loot (encounter_id, item_id, difficulty_id, drop_weight, drop_chance) VALUES (1503, 1783005, 5, 100, 1);
|
||||||
INSERT INTO encounter_loot (encounter_id, item_id, difficulty_id, drop_weight, drop_chance) VALUES (2003, 2283101, 101, 100, 1);
|
INSERT INTO encounter_loot (encounter_id, item_id, difficulty_id, drop_weight, drop_chance) VALUES (2003, 2283101, 101, 100, 1);
|
||||||
INSERT INTO encounter_loot (encounter_id, item_id, difficulty_id, drop_weight, drop_chance) VALUES (2103, 2286101, 101, 100, 1);
|
INSERT INTO encounter_loot (encounter_id, item_id, difficulty_id, drop_weight, drop_chance) SELECT 2103, id, 101, 100, 1 FROM items WHERE slug = 'rathalos-raid-boss-coin-diff-101-ilvl-10';
|
||||||
INSERT INTO encounter_loot (encounter_id, item_id, difficulty_id, drop_weight, drop_chance) VALUES (2203, 2289101, 101, 100, 1);
|
INSERT INTO encounter_loot (encounter_id, item_id, difficulty_id, drop_weight, drop_chance) SELECT 2203, id, 101, 100, 1 FROM items WHERE slug = 'gypceros-raid-boss-coin-diff-101-ilvl-10';
|
||||||
INSERT INTO encounter_loot (encounter_id, item_id, difficulty_id, drop_weight, drop_chance) VALUES (2303, 783103, 103, 100, 1);
|
INSERT INTO encounter_loot (encounter_id, item_id, difficulty_id, drop_weight, drop_chance) SELECT 2303, id, 103, 100, 1 FROM items WHERE slug = 'nargacuga-raid-boss-coin-diff-103-ilvl-15';
|
||||||
INSERT INTO encounter_loot (encounter_id, item_id, difficulty_id, drop_weight, drop_chance) VALUES (2403, 786103, 103, 100, 1);
|
INSERT INTO encounter_loot (encounter_id, item_id, difficulty_id, drop_weight, drop_chance) SELECT 2403, id, 103, 100, 1 FROM items WHERE slug = 'azuros-raid-boss-coin-diff-103-ilvl-15';
|
||||||
INSERT INTO encounter_loot (encounter_id, item_id, difficulty_id, drop_weight, drop_chance) VALUES (2503, 789103, 103, 100, 1);
|
INSERT INTO encounter_loot (encounter_id, item_id, difficulty_id, drop_weight, drop_chance) SELECT 2503, id, 103, 100, 1 FROM items WHERE slug = 'diablos-raid-boss-coin-diff-103-ilvl-15';
|
||||||
INSERT INTO encounter_loot (encounter_id, item_id, difficulty_id, drop_weight, drop_chance) VALUES (2603, 983104, 104, 100, 1);
|
INSERT INTO encounter_loot (encounter_id, item_id, difficulty_id, drop_weight, drop_chance) SELECT 2603, id, 104, 100, 1 FROM items WHERE slug = 'barroth-raid-boss-coin-diff-104-ilvl-20';
|
||||||
INSERT INTO encounter_loot (encounter_id, item_id, difficulty_id, drop_weight, drop_chance) VALUES (2703, 986104, 104, 100, 1);
|
INSERT INTO encounter_loot (encounter_id, item_id, difficulty_id, drop_weight, drop_chance) SELECT 2703, id, 104, 100, 1 FROM items WHERE slug = 'tobi-kadachi-raid-boss-coin-diff-104-ilvl-20';
|
||||||
INSERT INTO encounter_loot (encounter_id, item_id, difficulty_id, drop_weight, drop_chance) VALUES (2803, 989104, 104, 100, 1);
|
INSERT INTO encounter_loot (encounter_id, item_id, difficulty_id, drop_weight, drop_chance) SELECT 2803, id, 104, 100, 1 FROM items WHERE slug = 'monoblos-raid-boss-coin-diff-104-ilvl-20';
|
||||||
INSERT INTO encounter_loot (encounter_id, item_id, difficulty_id, drop_weight, drop_chance) VALUES (2903, 1183105, 105, 100, 1);
|
INSERT INTO encounter_loot (encounter_id, item_id, difficulty_id, drop_weight, drop_chance) SELECT 2903, id, 105, 100, 1 FROM items WHERE slug = 'anjanath-raid-boss-coin-diff-105-ilvl-25';
|
||||||
INSERT INTO encounter_loot (encounter_id, item_id, difficulty_id, drop_weight, drop_chance) VALUES (3003, 1186105, 105, 100, 1);
|
INSERT INTO encounter_loot (encounter_id, item_id, difficulty_id, drop_weight, drop_chance) SELECT 3003, id, 105, 100, 1 FROM items WHERE slug = 'bazelgeuse-raid-boss-coin-diff-105-ilvl-25';
|
||||||
INSERT INTO encounter_loot (encounter_id, item_id, difficulty_id, drop_weight, drop_chance) VALUES (3103, 1189105, 105, 100, 1);
|
INSERT INTO encounter_loot (encounter_id, item_id, difficulty_id, drop_weight, drop_chance) SELECT 3103, id, 105, 100, 1 FROM items WHERE slug = 'odogaron-raid-boss-coin-diff-105-ilvl-25';
|
||||||
|
|
||||||
UPDATE crafting_recipes SET difficulty_id = 1, source_dungeon_id = 1, source_encounter_id = 103 WHERE id = 1001;
|
UPDATE crafting_recipes SET difficulty_id = 1, source_dungeon_id = 1, source_encounter_id = 103 WHERE id = 1001;
|
||||||
UPDATE crafting_recipes SET difficulty_id = 1, source_dungeon_id = 1, source_encounter_id = 103 WHERE id = 1002;
|
UPDATE crafting_recipes SET difficulty_id = 1, source_dungeon_id = 1, source_encounter_id = 103 WHERE id = 1002;
|
||||||
@@ -532,42 +532,87 @@ INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (10
|
|||||||
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1007, 583001, 5);
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1007, 583001, 5);
|
||||||
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1008, 583001, 5);
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1008, 583001, 5);
|
||||||
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1009, 583001, 5);
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1009, 583001, 5);
|
||||||
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1101, 683002, 10);
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1101, 383002, 5);
|
||||||
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1102, 683002, 10);
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1101, 683002, 5);
|
||||||
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1103, 683002, 10);
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1102, 383002, 5);
|
||||||
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1104, 783002, 10);
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1102, 683002, 5);
|
||||||
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1105, 783002, 10);
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1103, 383002, 5);
|
||||||
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1106, 783002, 10);
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1103, 683002, 5);
|
||||||
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1107, 883002, 10);
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1104, 483002, 5);
|
||||||
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1108, 883002, 10);
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1104, 783002, 5);
|
||||||
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1109, 883002, 10);
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1105, 483002, 5);
|
||||||
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1301, 1283004, 20);
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1105, 783002, 5);
|
||||||
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1302, 1283004, 20);
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1106, 483002, 5);
|
||||||
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1303, 1283004, 20);
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1106, 783002, 5);
|
||||||
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1304, 1383004, 20);
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1107, 583002, 5);
|
||||||
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1305, 1383004, 20);
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1107, 883002, 5);
|
||||||
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1306, 1383004, 20);
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1108, 583002, 5);
|
||||||
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1307, 1483004, 20);
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1108, 883002, 5);
|
||||||
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1308, 1483004, 20);
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1109, 583002, 5);
|
||||||
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1309, 1483004, 20);
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1109, 883002, 5);
|
||||||
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1401, 1583005, 25);
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1201, 683003, 7);
|
||||||
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1402, 1583005, 25);
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1201, 983003, 8);
|
||||||
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1403, 1583005, 25);
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1202, 683003, 7);
|
||||||
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1404, 1683005, 25);
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1202, 983003, 8);
|
||||||
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1405, 1683005, 25);
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1203, 683003, 7);
|
||||||
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1406, 1683005, 25);
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1203, 983003, 8);
|
||||||
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1407, 1783005, 25);
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1204, 783003, 7);
|
||||||
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1408, 1783005, 25);
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1204, 1083003, 8);
|
||||||
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1409, 1783005, 25);
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1205, 783003, 7);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1205, 1083003, 8);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1206, 783003, 7);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1206, 1083003, 8);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1207, 883003, 7);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1207, 1183003, 8);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1208, 883003, 7);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1208, 1183003, 8);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1209, 883003, 7);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1209, 1183003, 8);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1301, 983004, 10);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1301, 1283004, 10);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1302, 983004, 10);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1302, 1283004, 10);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1303, 983004, 10);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1303, 1283004, 10);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1304, 1083004, 10);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1304, 1383004, 10);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1305, 1083004, 10);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1305, 1383004, 10);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1306, 1083004, 10);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1306, 1383004, 10);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1307, 1183004, 10);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1307, 1483004, 10);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1308, 1183004, 10);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1308, 1483004, 10);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1309, 1183004, 10);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1309, 1483004, 10);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1401, 1283005, 12);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1401, 1583005, 13);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1402, 1283005, 12);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1402, 1583005, 13);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1403, 1283005, 12);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1403, 1583005, 13);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1404, 1383005, 12);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1404, 1683005, 13);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1405, 1383005, 12);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1405, 1683005, 13);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1406, 1383005, 12);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1406, 1683005, 13);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1407, 1483005, 12);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1407, 1783005, 13);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1408, 1483005, 12);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1408, 1783005, 13);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1409, 1483005, 12);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1409, 1783005, 13);
|
||||||
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (2001, 2283101, 10);
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (2001, 2283101, 10);
|
||||||
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (2002, 2283101, 10);
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (2002, 2283101, 10);
|
||||||
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (2003, 2283101, 10);
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (2003, 2283101, 10);
|
||||||
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (2004, 2286101, 10);
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) SELECT 2004, id, 10 FROM items WHERE slug = 'rathalos-raid-boss-coin-diff-101-ilvl-10';
|
||||||
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (2005, 2286101, 10);
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) SELECT 2005, id, 10 FROM items WHERE slug = 'rathalos-raid-boss-coin-diff-101-ilvl-10';
|
||||||
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (2006, 2286101, 10);
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) SELECT 2006, id, 10 FROM items WHERE slug = 'rathalos-raid-boss-coin-diff-101-ilvl-10';
|
||||||
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (2007, 2289101, 10);
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) SELECT 2007, id, 10 FROM items WHERE slug = 'gypceros-raid-boss-coin-diff-101-ilvl-10';
|
||||||
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (2008, 2289101, 10);
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) SELECT 2008, id, 10 FROM items WHERE slug = 'gypceros-raid-boss-coin-diff-101-ilvl-10';
|
||||||
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (2009, 2289101, 10);
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) SELECT 2009, id, 10 FROM items WHERE slug = 'gypceros-raid-boss-coin-diff-101-ilvl-10';
|
||||||
|
|
||||||
DELETE FROM gear_upgrade_paths;
|
DELETE FROM gear_upgrade_paths;
|
||||||
INSERT INTO gear_upgrade_paths (from_item_id, to_item_id) VALUES (1, 201);
|
INSERT INTO gear_upgrade_paths (from_item_id, to_item_id) VALUES (1, 201);
|
||||||
|
|||||||
+15
@@ -1908,6 +1908,21 @@ JOIN coin_sources
|
|||||||
ON coin_sources.encounter_id = crafting_recipes.source_encounter_id
|
ON coin_sources.encounter_id = crafting_recipes.source_encounter_id
|
||||||
AND coin_sources.difficulty_id = crafting_recipes.difficulty_id;
|
AND coin_sources.difficulty_id = crafting_recipes.difficulty_id;
|
||||||
|
|
||||||
|
DELETE FROM crafting_recipe_components
|
||||||
|
WHERE recipe_id IN (1101, 1102, 1103);
|
||||||
|
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity)
|
||||||
|
SELECT recipe_id, items.id, quantity
|
||||||
|
FROM (
|
||||||
|
SELECT 1101 AS recipe_id, 'tigrex-boss-coin-diff-2-ilvl-10' AS item_slug, 5 AS quantity
|
||||||
|
UNION ALL SELECT 1101, 'bulldrome-boss-coin-diff-2-ilvl-10', 5
|
||||||
|
UNION ALL SELECT 1102, 'tigrex-boss-coin-diff-2-ilvl-10', 5
|
||||||
|
UNION ALL SELECT 1102, 'bulldrome-boss-coin-diff-2-ilvl-10', 5
|
||||||
|
UNION ALL SELECT 1103, 'tigrex-boss-coin-diff-2-ilvl-10', 5
|
||||||
|
UNION ALL SELECT 1103, 'bulldrome-boss-coin-diff-2-ilvl-10', 5
|
||||||
|
) AS requirements
|
||||||
|
JOIN items ON items.slug = requirements.item_slug;
|
||||||
|
|
||||||
DELETE FROM gear_upgrade_paths;
|
DELETE FROM gear_upgrade_paths;
|
||||||
|
|
||||||
INSERT INTO gear_upgrade_paths (from_item_id, to_item_id)
|
INSERT INTO gear_upgrade_paths (from_item_id, to_item_id)
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1440" height="900" viewBox="0 0 1440 900">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" x2="1" y1="0" y2="1"><stop offset="0" stop-color="#111827"/><stop offset="1" stop-color="#2f1f16"/></linearGradient>
|
||||||
|
<style>
|
||||||
|
text{font-family:Inter,Arial,sans-serif;fill:#f8f1df}.muted{fill:#c8bca6;font-size:20px}.tiny{fill:#d8cab1;font-size:16px}.title{font-size:30px;font-weight:800}.label{font-size:19px;font-weight:700}.panel{fill:#1b2433;stroke:#6f5535;stroke-width:2;rx:12}.tile{fill:#253244;stroke:#7d6743;stroke-width:2;rx:10}.selected{fill:#31483b;stroke:#d7aa45;stroke-width:4;rx:10}.bar-bg{fill:#2a3444;rx:8}.hp{fill:#5ed17a;rx:8}.danger{fill:#d96b55;rx:8}.mana{fill:#63a9ff;rx:8}.button{fill:#d7aa45;rx:10}.spell{fill:#273449;stroke:#8d7349;stroke-width:2;rx:10}
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<rect width="1440" height="900" fill="url(#bg)"/>
|
||||||
|
<text x="48" y="64" class="title">PC Dungeon PvP Roguelike</text>
|
||||||
|
<text x="48" y="96" class="muted">Dungeon party = 6 members per side. Upgrade timer auto-picks at 0s.</text>
|
||||||
|
|
||||||
|
<rect x="36" y="132" width="402" height="704" class="panel"/>
|
||||||
|
<text x="64" y="176" class="title">You</text><text x="64" y="204" class="muted">Mira - Priest</text>
|
||||||
|
<text x="64" y="252" class="label">Party Health</text>
|
||||||
|
<g transform="translate(64 278)">
|
||||||
|
<rect width="150" height="82" class="selected"/><text x="16" y="31" class="label">Tank</text><text x="106" y="31" class="tiny">82%</text><rect x="16" y="52" width="118" height="12" class="bar-bg"/><rect x="16" y="52" width="97" height="12" class="hp"/>
|
||||||
|
<rect x="170" width="150" height="82" class="tile"/><text x="186" y="31" class="label">Mira</text><text x="276" y="31" class="tiny">74%</text><rect x="186" y="52" width="118" height="12" class="bar-bg"/><rect x="186" y="52" width="87" height="12" class="hp"/>
|
||||||
|
<rect y="104" width="150" height="82" class="tile"/><text x="16" y="135" class="label">DPS</text><text x="106" y="135" class="tiny">55%</text><rect x="16" y="156" width="118" height="12" class="bar-bg"/><rect x="16" y="156" width="65" height="12" class="danger"/>
|
||||||
|
<rect x="170" y="104" width="150" height="82" class="tile"/><text x="186" y="135" class="label">DPS</text><text x="276" y="135" class="tiny">92%</text><rect x="186" y="156" width="118" height="12" class="bar-bg"/><rect x="186" y="156" width="109" height="12" class="hp"/>
|
||||||
|
<rect y="208" width="150" height="82" class="tile"/><text x="16" y="239" class="label">DPS</text><text x="106" y="239" class="tiny">66%</text><rect x="16" y="260" width="118" height="12" class="bar-bg"/><rect x="16" y="260" width="78" height="12" class="hp"/>
|
||||||
|
<rect x="170" y="208" width="150" height="82" class="tile"/><text x="186" y="239" class="label">DPS</text><text x="276" y="239" class="tiny">41%</text><rect x="186" y="260" width="118" height="12" class="bar-bg"/><rect x="186" y="260" width="48" height="12" class="danger"/>
|
||||||
|
</g>
|
||||||
|
<text x="64" y="626" class="tiny">Buffs: Wide Radiance x1</text><text x="64" y="654" class="tiny">Debuffs: Mana Squeeze x1</text>
|
||||||
|
|
||||||
|
<rect x="472" y="132" width="496" height="704" class="panel"/>
|
||||||
|
<text x="504" y="176" class="title">Stage 15 Boss</text><text x="504" y="204" class="muted">Bulldrome Guardian</text>
|
||||||
|
<text x="504" y="260" class="label">Your Clear</text><rect x="504" y="278" width="400" height="24" class="bar-bg"/><rect x="504" y="278" width="176" height="24" class="danger"/>
|
||||||
|
<text x="504" y="342" class="label">Astra Clear</text><rect x="504" y="360" width="400" height="24" class="bar-bg"/><rect x="504" y="360" width="246" height="24" class="danger"/>
|
||||||
|
<g transform="translate(520 436)">
|
||||||
|
<rect width="400" height="260" fill="#161f2c" stroke="#d7aa45" stroke-width="3" rx="14"/>
|
||||||
|
<text x="32" y="44" class="title">Choose Edge</text><text x="274" y="44" class="title">07.4s</text>
|
||||||
|
<text x="32" y="88" class="label">Self Buff</text><text x="210" y="88" class="label">Opponent Debuff</text>
|
||||||
|
<rect x="32" y="112" width="150" height="52" class="selected"/><text x="46" y="145" class="tiny">+1 Target</text>
|
||||||
|
<rect x="32" y="178" width="150" height="52" class="tile"/><text x="46" y="211" class="tiny">-25% Cost</text>
|
||||||
|
<rect x="210" y="112" width="150" height="52" class="tile"/><text x="224" y="145" class="tiny">Cost Up</text>
|
||||||
|
<rect x="210" y="178" width="150" height="52" class="selected"/><text x="224" y="211" class="tiny">Mana Squeeze</text>
|
||||||
|
</g>
|
||||||
|
<g transform="translate(520 728)">
|
||||||
|
<rect width="56" height="52" class="spell"/><text x="22" y="34" font-size="24" font-weight="800">+</text>
|
||||||
|
<rect x="68" width="56" height="52" class="spell"/><text x="88" y="34" font-size="24" font-weight="800">R</text>
|
||||||
|
<rect x="136" width="56" height="52" class="spell"/><text x="156" y="34" font-size="24" font-weight="800">S</text>
|
||||||
|
<rect x="204" width="56" height="52" class="spell"/><text x="224" y="34" font-size="24" font-weight="800">G</text>
|
||||||
|
<rect x="272" width="56" height="52" class="spell"/><text x="292" y="34" font-size="24" font-weight="800">C</text>
|
||||||
|
<rect x="340" width="56" height="52" class="spell"/><text x="360" y="34" font-size="24" font-weight="800">B</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<rect x="1002" y="132" width="402" height="704" class="panel"/>
|
||||||
|
<text x="1030" y="176" class="title">Opponent</text><text x="1030" y="204" class="muted">Astra - Druid</text>
|
||||||
|
<text x="1030" y="252" class="label">Opponent Party</text>
|
||||||
|
<g transform="translate(1030 278)">
|
||||||
|
<rect width="150" height="82" class="tile"/><text x="16" y="31" class="label">Tank</text><text x="106" y="31" class="tiny">59%</text><rect x="16" y="52" width="118" height="12" class="bar-bg"/><rect x="16" y="52" width="70" height="12" class="danger"/>
|
||||||
|
<rect x="170" width="150" height="82" class="tile"/><text x="186" y="31" class="label">Astra</text><text x="276" y="31" class="tiny">88%</text><rect x="186" y="52" width="118" height="12" class="bar-bg"/><rect x="186" y="52" width="104" height="12" class="hp"/>
|
||||||
|
<rect y="104" width="150" height="82" class="tile"/><text x="16" y="135" class="label">DPS</text><text x="106" y="135" class="tiny">73%</text><rect x="16" y="156" width="118" height="12" class="bar-bg"/><rect x="16" y="156" width="86" height="12" class="hp"/>
|
||||||
|
<rect x="170" y="104" width="150" height="82" class="tile"/><text x="186" y="135" class="label">DPS</text><text x="276" y="135" class="tiny">42%</text><rect x="186" y="156" width="118" height="12" class="bar-bg"/><rect x="186" y="156" width="50" height="12" class="danger"/>
|
||||||
|
<rect y="208" width="150" height="82" class="tile"/><text x="16" y="239" class="label">DPS</text><text x="106" y="239" class="tiny">91%</text><rect x="16" y="260" width="118" height="12" class="bar-bg"/><rect x="16" y="260" width="107" height="12" class="hp"/>
|
||||||
|
<rect x="170" y="208" width="150" height="82" class="tile"/><text x="186" y="239" class="label">DPS</text><text x="276" y="239" class="tiny">66%</text><rect x="186" y="260" width="118" height="12" class="bar-bg"/><rect x="186" y="260" width="78" height="12" class="hp"/>
|
||||||
|
</g>
|
||||||
|
<text x="1030" y="626" class="tiny">Buffs: Dense Shields x1</text><text x="1030" y="654" class="tiny">Debuffs: Cost Up x1</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 7.1 KiB |
@@ -0,0 +1,30 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540" viewBox="0 0 960 540">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" x2="1" y1="0" y2="1"><stop offset="0" stop-color="#101827"/><stop offset="1" stop-color="#271a12"/></linearGradient>
|
||||||
|
<style>
|
||||||
|
text{font-family:Inter,Arial,sans-serif;fill:#f8f1df}.muted{fill:#c8bca6;font-size:15px}.title{font-size:24px;font-weight:800}.label{font-size:16px;font-weight:700}.panel{fill:#1b2433;stroke:#6f5535;stroke-width:2;rx:10}.tile{fill:#253244;stroke:#7d6743;stroke-width:2;rx:8}.selected{fill:#31483b;stroke:#d7aa45;stroke-width:4;rx:8}.bar-bg{fill:#2a3444;rx:7}.hp{fill:#5ed17a;rx:7}.danger{fill:#d96b55;rx:7}.mana{fill:#63a9ff;rx:7}.spell{fill:#273449;stroke:#8d7349;stroke-width:2;rx:10}.ready{fill:#31483b;stroke:#d7aa45;stroke-width:3;rx:10}
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<rect width="960" height="540" fill="url(#bg)"/>
|
||||||
|
<text x="24" y="38" class="title">Thor Main - Dungeon PvP</text>
|
||||||
|
<text x="24" y="62" class="muted">Dungeon party shows exactly 6 members. Opponent health stays on bottom screen.</text>
|
||||||
|
<rect x="24" y="86" width="912" height="350" class="panel"/>
|
||||||
|
<text x="52" y="124" class="title">Mira Party</text><text x="760" y="124" class="label">Stage 15 Boss</text>
|
||||||
|
<rect x="760" y="140" width="132" height="16" class="bar-bg"/><rect x="760" y="140" width="58" height="16" class="danger"/>
|
||||||
|
<g transform="translate(52 164)">
|
||||||
|
<rect width="132" height="96" class="selected"/><text x="16" y="28" class="label">Tank</text><text x="16" y="52" class="muted">82 / 100</text><rect x="16" y="68" width="100" height="12" class="bar-bg"/><rect x="16" y="68" width="82" height="12" class="hp"/>
|
||||||
|
<rect x="150" width="132" height="96" class="tile"/><text x="166" y="28" class="label">Mira</text><text x="166" y="52" class="muted">74 / 100</text><rect x="166" y="68" width="100" height="12" class="bar-bg"/><rect x="166" y="68" width="74" height="12" class="hp"/>
|
||||||
|
<rect x="300" width="132" height="96" class="tile"/><text x="316" y="28" class="label">DPS</text><text x="316" y="52" class="muted">55 / 100</text><rect x="316" y="68" width="100" height="12" class="bar-bg"/><rect x="316" y="68" width="55" height="12" class="danger"/>
|
||||||
|
<rect y="116" width="132" height="96" class="tile"/><text x="16" y="144" class="label">DPS</text><text x="16" y="168" class="muted">92 / 100</text><rect x="16" y="184" width="100" height="12" class="bar-bg"/><rect x="16" y="184" width="92" height="12" class="hp"/>
|
||||||
|
<rect x="150" y="116" width="132" height="96" class="tile"/><text x="166" y="144" class="label">DPS</text><text x="166" y="168" class="muted">66 / 100</text><rect x="166" y="184" width="100" height="12" class="bar-bg"/><rect x="166" y="184" width="66" height="12" class="hp"/>
|
||||||
|
<rect x="300" y="116" width="132" height="96" class="tile"/><text x="316" y="144" class="label">DPS</text><text x="316" y="168" class="muted">41 / 100</text><rect x="316" y="184" width="100" height="12" class="bar-bg"/><rect x="316" y="184" width="41" height="12" class="danger"/>
|
||||||
|
</g>
|
||||||
|
<rect x="24" y="452" width="912" height="68" class="panel"/>
|
||||||
|
<rect x="48" y="468" width="52" height="40" class="ready"/><text x="66" y="496" font-size="22" font-weight="800">+</text>
|
||||||
|
<rect x="112" y="468" width="52" height="40" class="spell"/><text x="129" y="496" font-size="22" font-weight="800">R</text><text x="143" y="483" font-size="12">3</text>
|
||||||
|
<rect x="176" y="468" width="52" height="40" class="spell"/><text x="194" y="496" font-size="22" font-weight="800">S</text><text x="207" y="483" font-size="12">8</text>
|
||||||
|
<rect x="240" y="468" width="52" height="40" class="ready"/><text x="258" y="496" font-size="22" font-weight="800">G</text>
|
||||||
|
<rect x="304" y="468" width="52" height="40" class="spell"/><text x="322" y="496" font-size="22" font-weight="800">C</text><text x="335" y="483" font-size="12">5</text>
|
||||||
|
<rect x="368" y="468" width="52" height="40" class="ready"/><text x="386" y="496" font-size="22" font-weight="800">B</text>
|
||||||
|
<text x="650" y="480" class="label">Mana 73 / 100</text><rect x="650" y="492" width="240" height="14" class="bar-bg"/><rect x="650" y="492" width="175" height="14" class="mana"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.1 KiB |
@@ -0,0 +1,62 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1440" height="900" viewBox="0 0 1440 900">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" x2="1" y1="0" y2="1"><stop offset="0" stop-color="#111827"/><stop offset="1" stop-color="#2f1f16"/></linearGradient>
|
||||||
|
<style>
|
||||||
|
text{font-family:Inter,Arial,sans-serif;fill:#f8f1df}.muted{fill:#c8bca6;font-size:20px}.tiny{fill:#d8cab1;font-size:14px}.title{font-size:30px;font-weight:800}.label{font-size:18px;font-weight:700}.panel{fill:#1b2433;stroke:#6f5535;stroke-width:2;rx:12}.tile{fill:#253244;stroke:#7d6743;stroke-width:2;rx:8}.bar-bg{fill:#2a3444;rx:7}.hp{fill:#5ed17a;rx:7}.danger{fill:#d96b55;rx:7}.spell{fill:#273449;stroke:#8d7349;stroke-width:2;rx:10}.selected{fill:#31483b;stroke:#d7aa45;stroke-width:3;rx:8}
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<rect width="1440" height="900" fill="url(#bg)"/>
|
||||||
|
<text x="48" y="64" class="title">PC Raid PvP Roguelike</text>
|
||||||
|
<text x="48" y="96" class="muted">Raid party = compact roster. Opponent panel mirrors raid health without crowding center.</text>
|
||||||
|
<rect x="36" y="132" width="430" height="704" class="panel"/>
|
||||||
|
<text x="64" y="176" class="title">You</text><text x="64" y="204" class="muted">Mira Raid Group</text>
|
||||||
|
<g transform="translate(64 238)">
|
||||||
|
<rect width="104" height="56" class="selected"/><text x="10" y="24" class="tiny">Tank 82%</text><rect x="10" y="36" width="84" height="8" class="bar-bg"/><rect x="10" y="36" width="69" height="8" class="hp"/>
|
||||||
|
<rect x="116" width="104" height="56" class="tile"/><text x="126" y="24" class="tiny">Mira 74%</text><rect x="126" y="36" width="84" height="8" class="bar-bg"/><rect x="126" y="36" width="62" height="8" class="hp"/>
|
||||||
|
<rect x="232" width="104" height="56" class="tile"/><text x="242" y="24" class="tiny">DPS 55%</text><rect x="242" y="36" width="84" height="8" class="bar-bg"/><rect x="242" y="36" width="46" height="8" class="danger"/>
|
||||||
|
<rect y="72" width="104" height="56" class="tile"/><text x="10" y="96" class="tiny">DPS 92%</text><rect x="10" y="108" width="84" height="8" class="bar-bg"/><rect x="10" y="108" width="77" height="8" class="hp"/>
|
||||||
|
<rect x="116" y="72" width="104" height="56" class="tile"/><text x="126" y="96" class="tiny">DPS 66%</text><rect x="126" y="108" width="84" height="8" class="bar-bg"/><rect x="126" y="108" width="55" height="8" class="hp"/>
|
||||||
|
<rect x="232" y="72" width="104" height="56" class="tile"/><text x="242" y="96" class="tiny">DPS 41%</text><rect x="242" y="108" width="84" height="8" class="bar-bg"/><rect x="242" y="108" width="34" height="8" class="danger"/>
|
||||||
|
<rect y="144" width="104" height="56" class="tile"/><text x="10" y="168" class="tiny">DPS 88%</text><rect x="10" y="180" width="84" height="8" class="bar-bg"/><rect x="10" y="180" width="74" height="8" class="hp"/>
|
||||||
|
<rect x="116" y="144" width="104" height="56" class="tile"/><text x="126" y="168" class="tiny">DPS 63%</text><rect x="126" y="180" width="84" height="8" class="bar-bg"/><rect x="126" y="180" width="53" height="8" class="hp"/>
|
||||||
|
<rect x="232" y="144" width="104" height="56" class="tile"/><text x="242" y="168" class="tiny">DPS 77%</text><rect x="242" y="180" width="84" height="8" class="bar-bg"/><rect x="242" y="180" width="65" height="8" class="hp"/>
|
||||||
|
<rect y="216" width="104" height="56" class="tile"/><text x="10" y="240" class="tiny">DPS 49%</text><rect x="10" y="252" width="84" height="8" class="bar-bg"/><rect x="10" y="252" width="41" height="8" class="danger"/>
|
||||||
|
<rect x="116" y="216" width="104" height="56" class="tile"/><text x="126" y="240" class="tiny">DPS 96%</text><rect x="126" y="252" width="84" height="8" class="bar-bg"/><rect x="126" y="252" width="81" height="8" class="hp"/>
|
||||||
|
<rect x="232" y="216" width="104" height="56" class="tile"/><text x="242" y="240" class="tiny">DPS 70%</text><rect x="242" y="252" width="84" height="8" class="bar-bg"/><rect x="242" y="252" width="59" height="8" class="hp"/>
|
||||||
|
</g>
|
||||||
|
<text x="64" y="590" class="tiny">Direct target group: 1 / 2</text><text x="64" y="620" class="tiny">Buffs: Wide Radiance x1</text>
|
||||||
|
|
||||||
|
<rect x="502" y="132" width="436" height="704" class="panel"/>
|
||||||
|
<text x="534" y="176" class="title">Raid Stage 20 Boss</text><text x="534" y="204" class="muted">Ashen Warden</text>
|
||||||
|
<text x="534" y="260" class="label">Your Clear</text><rect x="534" y="278" width="340" height="24" class="bar-bg"/><rect x="534" y="278" width="176" height="24" class="danger"/>
|
||||||
|
<text x="534" y="342" class="label">Astra Clear</text><rect x="534" y="360" width="340" height="24" class="bar-bg"/><rect x="534" y="360" width="218" height="24" class="danger"/>
|
||||||
|
<rect x="534" y="438" width="340" height="220" fill="#161f2c" stroke="#d7aa45" stroke-width="3" rx="14"/>
|
||||||
|
<text x="558" y="482" class="title">Choose Edge</text><text x="766" y="482" class="title">10.0s</text>
|
||||||
|
<rect x="558" y="520" width="132" height="48" class="selected"/><text x="572" y="551" class="tiny">Raid Heal +</text>
|
||||||
|
<rect x="708" y="520" width="132" height="48" class="tile"/><text x="722" y="551" class="tiny">Mana Squeeze</text>
|
||||||
|
<rect x="558" y="584" width="132" height="48" class="tile"/><text x="572" y="615" class="tiny">Shield Boost</text>
|
||||||
|
<rect x="708" y="584" width="132" height="48" class="selected"/><text x="722" y="615" class="tiny">Cooldown Up</text>
|
||||||
|
<g transform="translate(534 724)">
|
||||||
|
<rect width="48" height="48" class="spell"/><text x="18" y="32" font-size="22" font-weight="800">+</text>
|
||||||
|
<rect x="60" width="48" height="48" class="spell"/><text x="77" y="32" font-size="22" font-weight="800">R</text>
|
||||||
|
<rect x="120" width="48" height="48" class="spell"/><text x="137" y="32" font-size="22" font-weight="800">S</text>
|
||||||
|
<rect x="180" width="48" height="48" class="spell"/><text x="197" y="32" font-size="22" font-weight="800">G</text>
|
||||||
|
<rect x="240" width="48" height="48" class="spell"/><text x="257" y="32" font-size="22" font-weight="800">C</text>
|
||||||
|
<rect x="300" width="48" height="48" class="spell"/><text x="317" y="32" font-size="22" font-weight="800">B</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<rect x="974" y="132" width="430" height="704" class="panel"/>
|
||||||
|
<text x="1002" y="176" class="title">Opponent Raid</text><text x="1002" y="204" class="muted">Astra Group</text>
|
||||||
|
<g transform="translate(1002 238)">
|
||||||
|
<rect width="104" height="56" class="tile"/><text x="10" y="24" class="tiny">Tank 61%</text><rect x="10" y="36" width="84" height="8" class="bar-bg"/><rect x="10" y="36" width="51" height="8" class="danger"/>
|
||||||
|
<rect x="116" width="104" height="56" class="tile"/><text x="126" y="24" class="tiny">Astra 89%</text><rect x="126" y="36" width="84" height="8" class="bar-bg"/><rect x="126" y="36" width="75" height="8" class="hp"/>
|
||||||
|
<rect x="232" width="104" height="56" class="tile"/><text x="242" y="24" class="tiny">DPS 73%</text><rect x="242" y="36" width="84" height="8" class="bar-bg"/><rect x="242" y="36" width="61" height="8" class="hp"/>
|
||||||
|
<rect y="72" width="104" height="56" class="tile"/><text x="10" y="96" class="tiny">DPS 42%</text><rect x="10" y="108" width="84" height="8" class="bar-bg"/><rect x="10" y="108" width="35" height="8" class="danger"/>
|
||||||
|
<rect x="116" y="72" width="104" height="56" class="tile"/><text x="126" y="96" class="tiny">DPS 91%</text><rect x="126" y="108" width="84" height="8" class="bar-bg"/><rect x="126" y="108" width="76" height="8" class="hp"/>
|
||||||
|
<rect x="232" y="72" width="104" height="56" class="tile"/><text x="242" y="96" class="tiny">DPS 66%</text><rect x="242" y="108" width="84" height="8" class="bar-bg"/><rect x="242" y="108" width="55" height="8" class="hp"/>
|
||||||
|
<rect y="144" width="104" height="56" class="tile"/><text x="10" y="168" class="tiny">DPS 79%</text><rect x="10" y="180" width="84" height="8" class="bar-bg"/><rect x="10" y="180" width="66" height="8" class="hp"/>
|
||||||
|
<rect x="116" y="144" width="104" height="56" class="tile"/><text x="126" y="168" class="tiny">DPS 58%</text><rect x="126" y="180" width="84" height="8" class="bar-bg"/><rect x="126" y="180" width="49" height="8" class="danger"/>
|
||||||
|
<rect x="232" y="144" width="104" height="56" class="tile"/><text x="242" y="168" class="tiny">DPS 84%</text><rect x="242" y="180" width="84" height="8" class="bar-bg"/><rect x="242" y="180" width="71" height="8" class="hp"/>
|
||||||
|
</g>
|
||||||
|
<text x="1002" y="590" class="tiny">Opponent buffs/debuffs visible here</text><text x="1002" y="620" class="tiny">Raid bottom screen can scroll second group if needed</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 8.3 KiB |
@@ -0,0 +1,36 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540" viewBox="0 0 960 540">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" x2="1" y1="0" y2="1"><stop offset="0" stop-color="#101827"/><stop offset="1" stop-color="#271a12"/></linearGradient>
|
||||||
|
<style>
|
||||||
|
text{font-family:Inter,Arial,sans-serif;fill:#f8f1df}.muted{fill:#c8bca6;font-size:15px}.title{font-size:24px;font-weight:800}.label{font-size:15px;font-weight:700}.panel{fill:#1b2433;stroke:#6f5535;stroke-width:2;rx:10}.tile{fill:#253244;stroke:#7d6743;stroke-width:2;rx:8}.selected{fill:#31483b;stroke:#d7aa45;stroke-width:4;rx:8}.bar-bg{fill:#2a3444;rx:7}.hp{fill:#5ed17a;rx:7}.danger{fill:#d96b55;rx:7}.mana{fill:#63a9ff;rx:7}.spell{fill:#273449;stroke:#8d7349;stroke-width:2;rx:10}.ready{fill:#31483b;stroke:#d7aa45;stroke-width:3;rx:10}
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<rect width="960" height="540" fill="url(#bg)"/>
|
||||||
|
<text x="24" y="38" class="title">Thor Main - Raid PvP</text>
|
||||||
|
<text x="24" y="62" class="muted">Raid party uses compact tiles plus target group toggle. Bottom screen mirrors opponent raid.</text>
|
||||||
|
<rect x="24" y="86" width="912" height="350" class="panel"/>
|
||||||
|
<text x="52" y="122" class="title">Mira Raid</text><text x="214" y="122" class="muted">Target Group 1 / 2</text>
|
||||||
|
<text x="760" y="122" class="label">Raid Boss</text><rect x="760" y="138" width="132" height="16" class="bar-bg"/><rect x="760" y="138" width="72" height="16" class="danger"/>
|
||||||
|
<g transform="translate(52 156)">
|
||||||
|
<rect width="104" height="72" class="selected"/><text x="12" y="28" class="label">Tank</text><text x="62" y="28" class="muted">82%</text><rect x="12" y="46" width="80" height="10" class="bar-bg"/><rect x="12" y="46" width="66" height="10" class="hp"/>
|
||||||
|
<rect x="118" width="104" height="72" class="tile"/><text x="130" y="28" class="label">Mira</text><text x="180" y="28" class="muted">74%</text><rect x="130" y="46" width="80" height="10" class="bar-bg"/><rect x="130" y="46" width="59" height="10" class="hp"/>
|
||||||
|
<rect x="236" width="104" height="72" class="tile"/><text x="248" y="28" class="label">DPS</text><text x="298" y="28" class="muted">55%</text><rect x="248" y="46" width="80" height="10" class="bar-bg"/><rect x="248" y="46" width="44" height="10" class="danger"/>
|
||||||
|
<rect x="354" width="104" height="72" class="tile"/><text x="366" y="28" class="label">DPS</text><text x="416" y="28" class="muted">92%</text><rect x="366" y="46" width="80" height="10" class="bar-bg"/><rect x="366" y="46" width="74" height="10" class="hp"/>
|
||||||
|
<rect x="472" width="104" height="72" class="tile"/><text x="484" y="28" class="label">DPS</text><text x="534" y="28" class="muted">66%</text><rect x="484" y="46" width="80" height="10" class="bar-bg"/><rect x="484" y="46" width="53" height="10" class="hp"/>
|
||||||
|
<rect x="590" width="104" height="72" class="tile"/><text x="602" y="28" class="label">DPS</text><text x="652" y="28" class="muted">41%</text><rect x="602" y="46" width="80" height="10" class="bar-bg"/><rect x="602" y="46" width="33" height="10" class="danger"/>
|
||||||
|
<rect y="92" width="104" height="72" class="tile"/><text x="12" y="120" class="label">DPS</text><text x="62" y="120" class="muted">88%</text><rect x="12" y="138" width="80" height="10" class="bar-bg"/><rect x="12" y="138" width="70" height="10" class="hp"/>
|
||||||
|
<rect x="118" y="92" width="104" height="72" class="tile"/><text x="130" y="120" class="label">DPS</text><text x="180" y="120" class="muted">63%</text><rect x="130" y="138" width="80" height="10" class="bar-bg"/><rect x="130" y="138" width="50" height="10" class="hp"/>
|
||||||
|
<rect x="236" y="92" width="104" height="72" class="tile"/><text x="248" y="120" class="label">DPS</text><text x="298" y="120" class="muted">77%</text><rect x="248" y="138" width="80" height="10" class="bar-bg"/><rect x="248" y="138" width="62" height="10" class="hp"/>
|
||||||
|
<rect x="354" y="92" width="104" height="72" class="tile"/><text x="366" y="120" class="label">DPS</text><text x="416" y="120" class="muted">49%</text><rect x="366" y="138" width="80" height="10" class="bar-bg"/><rect x="366" y="138" width="39" height="10" class="danger"/>
|
||||||
|
<rect x="472" y="92" width="104" height="72" class="tile"/><text x="484" y="120" class="label">DPS</text><text x="534" y="120" class="muted">96%</text><rect x="484" y="138" width="80" height="10" class="bar-bg"/><rect x="484" y="138" width="77" height="10" class="hp"/>
|
||||||
|
<rect x="590" y="92" width="104" height="72" class="tile"/><text x="602" y="120" class="label">DPS</text><text x="652" y="120" class="muted">70%</text><rect x="602" y="138" width="80" height="10" class="bar-bg"/><rect x="602" y="138" width="56" height="10" class="hp"/>
|
||||||
|
</g>
|
||||||
|
<rect x="24" y="452" width="912" height="68" class="panel"/>
|
||||||
|
<rect x="48" y="468" width="52" height="40" class="ready"/><text x="66" y="496" font-size="22" font-weight="800">+</text>
|
||||||
|
<rect x="112" y="468" width="52" height="40" class="spell"/><text x="129" y="496" font-size="22" font-weight="800">R</text><text x="143" y="483" font-size="12">3</text>
|
||||||
|
<rect x="176" y="468" width="52" height="40" class="spell"/><text x="194" y="496" font-size="22" font-weight="800">S</text><text x="207" y="483" font-size="12">8</text>
|
||||||
|
<rect x="240" y="468" width="52" height="40" class="ready"/><text x="258" y="496" font-size="22" font-weight="800">G</text>
|
||||||
|
<rect x="304" y="468" width="52" height="40" class="spell"/><text x="322" y="496" font-size="22" font-weight="800">C</text><text x="335" y="483" font-size="12">5</text>
|
||||||
|
<rect x="368" y="468" width="52" height="40" class="ready"/><text x="386" y="496" font-size="22" font-weight="800">B</text>
|
||||||
|
<text x="650" y="480" class="label">Mana 73 / 100</text><rect x="650" y="492" width="240" height="14" class="bar-bg"/><rect x="650" y="492" width="175" height="14" class="mana"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 5.7 KiB |
@@ -0,0 +1,41 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="620" height="540" viewBox="0 0 620 540">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" x2="1" y1="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#101827"/>
|
||||||
|
<stop offset="1" stop-color="#271a12"/>
|
||||||
|
</linearGradient>
|
||||||
|
<style>
|
||||||
|
text { font-family: Inter, Arial, sans-serif; fill: #f8f1df; }
|
||||||
|
.muted { fill: #c8bca6; font-size: 14px; }
|
||||||
|
.title { font-size: 22px; font-weight: 800; }
|
||||||
|
.label { font-size: 15px; font-weight: 700; }
|
||||||
|
.panel { fill: #1b2433; stroke: #6f5535; stroke-width: 2; rx: 10; }
|
||||||
|
.tile { fill: #253244; stroke: #7d6743; stroke-width: 2; rx: 8; }
|
||||||
|
.bar-bg { fill: #2a3444; rx: 7; }
|
||||||
|
.hp { fill: #5ed17a; rx: 7; }
|
||||||
|
.danger { fill: #d96b55; rx: 7; }
|
||||||
|
.mana { fill: #63a9ff; rx: 7; }
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<rect width="620" height="540" fill="url(#bg)"/>
|
||||||
|
<text x="20" y="36" class="title">Thor Bottom Screen - Opponent View</text>
|
||||||
|
<text x="20" y="60" class="muted">Secondary display shows real opponent health/progress.</text>
|
||||||
|
<rect x="20" y="82" width="580" height="424" class="panel"/>
|
||||||
|
<text x="44" y="122" class="title">Astra</text>
|
||||||
|
<text x="44" y="146" class="muted">Druid - live opponent</text>
|
||||||
|
<text x="376" y="122" class="label">Opponent Clear</text>
|
||||||
|
<rect x="376" y="136" width="172" height="16" class="bar-bg"/><rect x="376" y="136" width="106" height="16" class="danger"/>
|
||||||
|
<text x="44" y="192" class="label">Opponent Party Health</text>
|
||||||
|
<g transform="translate(44 216)">
|
||||||
|
<rect width="158" height="72" class="tile"/><text x="14" y="26" class="label">Tank</text><text x="104" y="26" class="muted">59%</text><rect x="14" y="44" width="128" height="12" class="bar-bg"/><rect x="14" y="44" width="76" height="12" class="danger"/>
|
||||||
|
<rect x="176" width="158" height="72" class="tile"/><text x="190" y="26" class="label">Astra</text><text x="280" y="26" class="muted">88%</text><rect x="190" y="44" width="128" height="12" class="bar-bg"/><rect x="190" y="44" width="113" height="12" class="hp"/>
|
||||||
|
<rect x="352" width="158" height="72" class="tile"/><text x="366" y="26" class="label">DPS</text><text x="456" y="26" class="muted">73%</text><rect x="366" y="44" width="128" height="12" class="bar-bg"/><rect x="366" y="44" width="93" height="12" class="hp"/>
|
||||||
|
<rect y="92" width="158" height="72" class="tile"/><text x="14" y="118" class="label">DPS</text><text x="104" y="118" class="muted">42%</text><rect x="14" y="136" width="128" height="12" class="bar-bg"/><rect x="14" y="136" width="54" height="12" class="danger"/>
|
||||||
|
<rect x="176" y="92" width="158" height="72" class="tile"/><text x="190" y="118" class="label">DPS</text><text x="280" y="118" class="muted">91%</text><rect x="190" y="136" width="128" height="12" class="bar-bg"/><rect x="190" y="136" width="116" height="12" class="hp"/>
|
||||||
|
<rect x="352" y="92" width="158" height="72" class="tile"/><text x="366" y="118" class="label">DPS</text><text x="456" y="118" class="muted">66%</text><rect x="366" y="136" width="128" height="12" class="bar-bg"/><rect x="366" y="136" width="84" height="12" class="hp"/>
|
||||||
|
</g>
|
||||||
|
<text x="44" y="420" class="label">Mana 86 / 100</text>
|
||||||
|
<rect x="154" y="408" width="214" height="16" class="bar-bg"/><rect x="154" y="408" width="184" height="16" class="mana"/>
|
||||||
|
<text x="44" y="462" class="muted">Buffs: Dense Shields x1</text>
|
||||||
|
<text x="318" y="462" class="muted">Debuffs: Cost Up x1</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.4 KiB |
@@ -1,5 +1,6 @@
|
|||||||
import { readFileSync, writeFileSync } from 'node:fs'
|
import { readFileSync, writeFileSync } from 'node:fs'
|
||||||
import { DatabaseSync } from 'node:sqlite'
|
import { DatabaseSync } from 'node:sqlite'
|
||||||
|
import { catalogPayload } from '../server/catalog.mjs'
|
||||||
import { getProfile } from '../server/game-api.mjs'
|
import { getProfile } from '../server/game-api.mjs'
|
||||||
|
|
||||||
const database = new DatabaseSync(':memory:')
|
const database = new DatabaseSync(':memory:')
|
||||||
@@ -7,10 +8,14 @@ const database = new DatabaseSync(':memory:')
|
|||||||
try {
|
try {
|
||||||
database.exec(readFileSync('db/schema.sql', 'utf8'))
|
database.exec(readFileSync('db/schema.sql', 'utf8'))
|
||||||
database.exec(readFileSync('db/seed.sql', 'utf8'))
|
database.exec(readFileSync('db/seed.sql', 'utf8'))
|
||||||
const profile = getProfile(database, 1)
|
const catalog = catalogPayload(getProfile(database, 1))
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
'src/offline-starter-profile.json',
|
'src/offline-starter-profile.json',
|
||||||
`${JSON.stringify(profile, null, 2)}\n`,
|
`${JSON.stringify(catalog.profile, null, 2)}\n`,
|
||||||
|
)
|
||||||
|
writeFileSync(
|
||||||
|
'src/offline-catalog-meta.ts',
|
||||||
|
`export const bundledCatalogHash = '${catalog.hash}'\n`,
|
||||||
)
|
)
|
||||||
console.log('Offline starter profile exported from SQLite.')
|
console.log('Offline starter profile exported from SQLite.')
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
+33
-1
@@ -375,6 +375,31 @@ const server = createServer(async (request, response) => {
|
|||||||
SELECT from_item_id AS fromItemId, to_item_id AS toItemId
|
SELECT from_item_id AS fromItemId, to_item_id AS toItemId
|
||||||
FROM gear_upgrade_paths ORDER BY from_item_id
|
FROM gear_upgrade_paths ORDER BY from_item_id
|
||||||
`).all()
|
`).all()
|
||||||
|
const classes = database.prepare(`
|
||||||
|
SELECT id, slug, name, resource_name AS resourceName,
|
||||||
|
max_resource AS maxResource, theme_color AS themeColor, description
|
||||||
|
FROM classes ORDER BY id
|
||||||
|
`).all()
|
||||||
|
const abilities = database.prepare(`
|
||||||
|
SELECT id, class_id AS classId, slug, name, spell_type AS spellType,
|
||||||
|
resource_cost AS cost, cooldown_seconds AS cooldown, power,
|
||||||
|
unlock_level AS unlockLevel, glyph, description
|
||||||
|
FROM spells ORDER BY class_id, unlock_level, id
|
||||||
|
`).all()
|
||||||
|
const talents = database.prepare(`
|
||||||
|
SELECT talents.id, talents.class_id AS classId, talents.slug, talents.name,
|
||||||
|
talents.max_rank AS maxRank, talents.tier, talents.branch,
|
||||||
|
talents.prerequisite_talent_id AS prerequisiteTalentId,
|
||||||
|
talents.prerequisite_rank AS prerequisiteRank,
|
||||||
|
prerequisite.name AS prerequisiteName,
|
||||||
|
talents.effect_type AS effectType,
|
||||||
|
talents.effect_value_per_rank AS effectValuePerRank,
|
||||||
|
talents.glyph, talents.description
|
||||||
|
FROM talents
|
||||||
|
LEFT JOIN talents AS prerequisite
|
||||||
|
ON prerequisite.id = talents.prerequisite_talent_id
|
||||||
|
ORDER BY talents.class_id, talents.tier, talents.branch
|
||||||
|
`).all()
|
||||||
sendJson(response, 200, {
|
sendJson(response, 200, {
|
||||||
items,
|
items,
|
||||||
encounters,
|
encounters,
|
||||||
@@ -383,6 +408,11 @@ const server = createServer(async (request, response) => {
|
|||||||
craftingRecipes: [...recipes.values()],
|
craftingRecipes: [...recipes.values()],
|
||||||
dungeons,
|
dungeons,
|
||||||
gearUpgradePaths,
|
gearUpgradePaths,
|
||||||
|
classes: classes.map((gameClass) => ({
|
||||||
|
...gameClass,
|
||||||
|
abilities: abilities.filter((ability) => ability.classId === gameClass.id),
|
||||||
|
talents: talents.filter((talent) => talent.classId === gameClass.id),
|
||||||
|
})),
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -499,12 +529,14 @@ const server = createServer(async (request, response) => {
|
|||||||
const recipeComponents = request.url.match(/^\/api\/admin\/crafting-recipes\/(\d+)\/components$/)
|
const recipeComponents = request.url.match(/^\/api\/admin\/crafting-recipes\/(\d+)\/components$/)
|
||||||
if (recipeComponents && request.method === 'POST') {
|
if (recipeComponents && request.method === 'POST') {
|
||||||
const payload = await readJson(request)
|
const payload = await readJson(request)
|
||||||
|
const quantity = Number(payload.quantity)
|
||||||
|
if (!Number.isInteger(quantity) || quantity < 1) throw new Error('Component quantity must be at least 1.')
|
||||||
database.prepare(`
|
database.prepare(`
|
||||||
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity)
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity)
|
||||||
VALUES (?, ?, ?)
|
VALUES (?, ?, ?)
|
||||||
ON CONFLICT(recipe_id, item_id)
|
ON CONFLICT(recipe_id, item_id)
|
||||||
DO UPDATE SET quantity = excluded.quantity
|
DO UPDATE SET quantity = excluded.quantity
|
||||||
`).run(Number(recipeComponents[1]), payload.itemId, payload.quantity)
|
`).run(Number(recipeComponents[1]), payload.itemId, quantity)
|
||||||
writeAdminOverrides(database)
|
writeAdminOverrides(database)
|
||||||
sendJson(response, 200, { ok: true })
|
sendJson(response, 200, { ok: true })
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { createHash } from 'node:crypto'
|
||||||
|
|
||||||
|
function normalizeRecipe(recipe) {
|
||||||
|
return {
|
||||||
|
...recipe,
|
||||||
|
components: recipe.components.map((component) => ({
|
||||||
|
...component,
|
||||||
|
owned: 0,
|
||||||
|
})),
|
||||||
|
canCraft: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDungeon(dungeon) {
|
||||||
|
return {
|
||||||
|
...dungeon,
|
||||||
|
completionCount: 0,
|
||||||
|
leaderboard: [],
|
||||||
|
leaderboards: {
|
||||||
|
part_1: [],
|
||||||
|
part_2: [],
|
||||||
|
part_3: [],
|
||||||
|
full_run: [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeCatalogProfile(profile) {
|
||||||
|
return {
|
||||||
|
...profile,
|
||||||
|
character: {
|
||||||
|
...profile.character,
|
||||||
|
id: 1,
|
||||||
|
name: 'Mira',
|
||||||
|
level: 1,
|
||||||
|
experience: 0,
|
||||||
|
talentPoints: 1,
|
||||||
|
currentLevelExperience: 0,
|
||||||
|
nextLevelExperience: 100,
|
||||||
|
},
|
||||||
|
abilitySlots: profile.abilitySlots,
|
||||||
|
allocatedTalentPoints: 0,
|
||||||
|
inventory: [],
|
||||||
|
completedDungeonParts: 0,
|
||||||
|
completedRaidPhases: 0,
|
||||||
|
gearStats: {
|
||||||
|
averageItemLevel: 0,
|
||||||
|
healingPower: 0,
|
||||||
|
maxResourceBonus: 0,
|
||||||
|
},
|
||||||
|
setBonuses: profile.setBonuses.map((bonus) => ({
|
||||||
|
...bonus,
|
||||||
|
equippedPieces: 0,
|
||||||
|
active: false,
|
||||||
|
})),
|
||||||
|
craftingRecipes: profile.craftingRecipes.map(normalizeRecipe),
|
||||||
|
dungeons: profile.dungeons.map(normalizeDungeon),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function catalogHash(profile) {
|
||||||
|
return createHash('sha256')
|
||||||
|
.update(JSON.stringify(profile))
|
||||||
|
.digest('hex')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function catalogPayload(profile) {
|
||||||
|
const normalized = normalizeCatalogProfile(profile)
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
hash: catalogHash(normalized),
|
||||||
|
profile: normalized,
|
||||||
|
}
|
||||||
|
}
|
||||||
+322
-7
@@ -9,6 +9,7 @@ import {
|
|||||||
import { isIP } from 'node:net'
|
import { isIP } from 'node:net'
|
||||||
import { extname, resolve, sep } from 'node:path'
|
import { extname, resolve, sep } from 'node:path'
|
||||||
import { DatabaseSync } from 'node:sqlite'
|
import { DatabaseSync } from 'node:sqlite'
|
||||||
|
import { catalogPayload } from './catalog.mjs'
|
||||||
|
|
||||||
const databasePath = fileURLToPath(new URL('../data/game.db', import.meta.url))
|
const databasePath = fileURLToPath(new URL('../data/game.db', import.meta.url))
|
||||||
const bossImageDirectory = fileURLToPath(new URL('../data/uploads/bosses/', import.meta.url))
|
const bossImageDirectory = fileURLToPath(new URL('../data/uploads/bosses/', import.meta.url))
|
||||||
@@ -26,6 +27,10 @@ const directCraftItemLevels = new Set([1, 10, 20, 25])
|
|||||||
const sessionCookieName = 'chronicle_session'
|
const sessionCookieName = 'chronicle_session'
|
||||||
const sessionLifetimeSeconds = 60 * 60 * 24 * 30
|
const sessionLifetimeSeconds = 60 * 60 * 24 * 30
|
||||||
const rateLimitBuckets = new Map()
|
const rateLimitBuckets = new Map()
|
||||||
|
const pvpQueue = new Map()
|
||||||
|
const pvpMatches = new Map()
|
||||||
|
const pvpQueueTtlMs = 15 * 1000
|
||||||
|
const pvpMatchTtlMs = 60 * 60 * 1000
|
||||||
|
|
||||||
function sendJson(response, status, body, headers = {}) {
|
function sendJson(response, status, body, headers = {}) {
|
||||||
response.statusCode = status
|
response.statusCode = status
|
||||||
@@ -348,10 +353,13 @@ function currentSession(database, request) {
|
|||||||
accounts.id AS accountId,
|
accounts.id AS accountId,
|
||||||
accounts.username,
|
accounts.username,
|
||||||
characters.id AS characterId,
|
characters.id AS characterId,
|
||||||
characters.class_id AS classId
|
characters.class_id AS classId,
|
||||||
|
characters.name AS characterName,
|
||||||
|
classes.name AS className
|
||||||
FROM sessions
|
FROM sessions
|
||||||
JOIN accounts ON accounts.id = sessions.account_id
|
JOIN accounts ON accounts.id = sessions.account_id
|
||||||
JOIN characters ON characters.id = sessions.active_character_id
|
JOIN characters ON characters.id = sessions.active_character_id
|
||||||
|
JOIN classes ON classes.id = characters.class_id
|
||||||
WHERE sessions.token_hash = ?
|
WHERE sessions.token_hash = ?
|
||||||
AND sessions.expires_at > CURRENT_TIMESTAMP
|
AND sessions.expires_at > CURRENT_TIMESTAMP
|
||||||
`).get(tokenHash(token)) ?? null
|
`).get(tokenHash(token)) ?? null
|
||||||
@@ -858,6 +866,8 @@ export function getProfile(database, characterId, accountId) {
|
|||||||
quantity,
|
quantity,
|
||||||
owned,
|
owned,
|
||||||
}))
|
}))
|
||||||
|
const hasRequiredComponents = components.length > 0
|
||||||
|
&& components.every((component) => component.quantity > 0)
|
||||||
const { itemId, slug, name, slot, rarity, itemLevel, healingPower, maxResourceBonus, glyph, description, setId, setSlug, setName } = recipe
|
const { itemId, slug, name, slot, rarity, itemLevel, healingPower, maxResourceBonus, glyph, description, setId, setSlug, setName } = recipe
|
||||||
return {
|
return {
|
||||||
id: recipe.id,
|
id: recipe.id,
|
||||||
@@ -880,7 +890,8 @@ export function getProfile(database, characterId, accountId) {
|
|||||||
setName,
|
setName,
|
||||||
},
|
},
|
||||||
components,
|
components,
|
||||||
canCraft: components.every((component) => component.owned >= component.quantity),
|
canCraft: hasRequiredComponents
|
||||||
|
&& components.every((component) => component.owned >= component.quantity),
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
dungeons: dungeons.map((dungeon) => ({
|
dungeons: dungeons.map((dungeon) => ({
|
||||||
@@ -1746,6 +1757,9 @@ function craftItem(database, characterId, recipeId) {
|
|||||||
WHERE crafting_recipe_components.recipe_id = ?
|
WHERE crafting_recipe_components.recipe_id = ?
|
||||||
`).all(characterId, recipeId)
|
`).all(characterId, recipeId)
|
||||||
if (components.length === 0) throw new Error('That recipe has no component requirements.')
|
if (components.length === 0) throw new Error('That recipe has no component requirements.')
|
||||||
|
if (components.some((component) => component.quantity <= 0)) {
|
||||||
|
throw new Error('Recipe components must require at least one item.')
|
||||||
|
}
|
||||||
const missing = components.find((component) => component.owned < component.quantity)
|
const missing = components.find((component) => component.owned < component.quantity)
|
||||||
if (missing) {
|
if (missing) {
|
||||||
const item = itemById(database, missing.itemId)
|
const item = itemById(database, missing.itemId)
|
||||||
@@ -1845,6 +1859,10 @@ function upgradeItem(database, characterId, itemId) {
|
|||||||
AND character_inventory.character_id = ?
|
AND character_inventory.character_id = ?
|
||||||
WHERE crafting_recipe_components.recipe_id = ?
|
WHERE crafting_recipe_components.recipe_id = ?
|
||||||
`).all(characterId, targetRecipe.id)
|
`).all(characterId, targetRecipe.id)
|
||||||
|
if (components.length === 0) throw new Error('That upgrade has no component requirements.')
|
||||||
|
if (components.some((component) => component.quantity <= 0)) {
|
||||||
|
throw new Error('Upgrade components must require at least one item.')
|
||||||
|
}
|
||||||
const missing = components.find((component) => component.owned < component.quantity)
|
const missing = components.find((component) => component.owned < component.quantity)
|
||||||
if (missing) {
|
if (missing) {
|
||||||
const componentItem = itemById(database, missing.itemId)
|
const componentItem = itemById(database, missing.itemId)
|
||||||
@@ -2289,7 +2307,10 @@ function completeRoguelike(database, characterId, accountId, runMetrics) {
|
|||||||
const bossesCleared = Number(runMetrics?.bossesCleared ?? Math.floor(encountersCleared / 3))
|
const bossesCleared = Number(runMetrics?.bossesCleared ?? Math.floor(encountersCleared / 3))
|
||||||
const experienceMode = runMetrics?.experienceMode === 'pvp-boss-quarter-level'
|
const experienceMode = runMetrics?.experienceMode === 'pvp-boss-quarter-level'
|
||||||
? 'pvp-boss-quarter-level'
|
? 'pvp-boss-quarter-level'
|
||||||
: 'default'
|
: runMetrics?.experienceMode === 'pvp-fight-twelfth-level'
|
||||||
|
? 'pvp-fight-twelfth-level'
|
||||||
|
: 'default'
|
||||||
|
const fightsCleared = Number(runMetrics?.fightsCleared ?? encountersCleared)
|
||||||
const resourceSpent = Number(runMetrics?.resourceSpent)
|
const resourceSpent = Number(runMetrics?.resourceSpent)
|
||||||
const durationSeconds = Number(runMetrics?.durationSeconds)
|
const durationSeconds = Number(runMetrics?.durationSeconds)
|
||||||
if (!Number.isInteger(dungeonId) || dungeonId < 1) {
|
if (!Number.isInteger(dungeonId) || dungeonId < 1) {
|
||||||
@@ -2304,6 +2325,9 @@ function completeRoguelike(database, characterId, accountId, runMetrics) {
|
|||||||
if (!Number.isInteger(bossesCleared) || bossesCleared < 0 || bossesCleared > 100000) {
|
if (!Number.isInteger(bossesCleared) || bossesCleared < 0 || bossesCleared > 100000) {
|
||||||
throw new Error('The roguelike boss total is invalid.')
|
throw new Error('The roguelike boss total is invalid.')
|
||||||
}
|
}
|
||||||
|
if (!Number.isInteger(fightsCleared) || fightsCleared < 0 || fightsCleared > 100000) {
|
||||||
|
throw new Error('The roguelike fight total is invalid.')
|
||||||
|
}
|
||||||
if (!Number.isInteger(resourceSpent) || resourceSpent < 0 || resourceSpent > 100000) {
|
if (!Number.isInteger(resourceSpent) || resourceSpent < 0 || resourceSpent > 100000) {
|
||||||
throw new Error('The run resource total is invalid.')
|
throw new Error('The run resource total is invalid.')
|
||||||
}
|
}
|
||||||
@@ -2356,14 +2380,15 @@ function completeRoguelike(database, characterId, accountId, runMetrics) {
|
|||||||
`).get(maxLevel).experienceRequired
|
`).get(maxLevel).experienceRequired
|
||||||
let newExperience = character.experience
|
let newExperience = character.experience
|
||||||
let newLevel = character.level
|
let newLevel = character.level
|
||||||
if (experienceMode === 'pvp-boss-quarter-level') {
|
if (experienceMode === 'pvp-boss-quarter-level' || experienceMode === 'pvp-fight-twelfth-level') {
|
||||||
const catchUpTargetLevel = database.prepare(`
|
const catchUpTargetLevel = database.prepare(`
|
||||||
SELECT COALESCE(MAX(level), 0) AS level
|
SELECT COALESCE(MAX(level), 0) AS level
|
||||||
FROM characters
|
FROM characters
|
||||||
WHERE account_id = ?
|
WHERE account_id = ?
|
||||||
AND id != ?
|
AND id != ?
|
||||||
`).get(accountId, characterId).level
|
`).get(accountId, characterId).level
|
||||||
for (let bossIndex = 0; bossIndex < bossesCleared && newExperience < maxExperience; bossIndex += 1) {
|
const rewardUnits = experienceMode === 'pvp-boss-quarter-level' ? bossesCleared : fightsCleared
|
||||||
|
for (let rewardIndex = 0; rewardIndex < rewardUnits && newExperience < maxExperience; rewardIndex += 1) {
|
||||||
const currentLevelFloor = database.prepare(`
|
const currentLevelFloor = database.prepare(`
|
||||||
SELECT experience_required AS experienceRequired
|
SELECT experience_required AS experienceRequired
|
||||||
FROM level_progression
|
FROM level_progression
|
||||||
@@ -2377,7 +2402,9 @@ function completeRoguelike(database, characterId, accountId, runMetrics) {
|
|||||||
WHERE level = ?
|
WHERE level = ?
|
||||||
`).get(newLevel + 1).experienceRequired
|
`).get(newLevel + 1).experienceRequired
|
||||||
const levelBand = Math.max(1, nextLevelExperience - currentLevelFloor)
|
const levelBand = Math.max(1, nextLevelExperience - currentLevelFloor)
|
||||||
const rewardRate = catchUpTargetLevel > newLevel ? 0.5 : 0.25
|
const rewardRate = experienceMode === 'pvp-boss-quarter-level'
|
||||||
|
? (catchUpTargetLevel > newLevel ? 0.5 : 0.25)
|
||||||
|
: (catchUpTargetLevel > newLevel ? 1 / 6 : 1 / 12)
|
||||||
newExperience = Math.min(maxExperience, newExperience + Math.round(levelBand * rewardRate))
|
newExperience = Math.min(maxExperience, newExperience + Math.round(levelBand * rewardRate))
|
||||||
newLevel = database.prepare(`
|
newLevel = database.prepare(`
|
||||||
SELECT MAX(level) AS level
|
SELECT MAX(level) AS level
|
||||||
@@ -2527,6 +2554,246 @@ function saveProfile(database, characterId, accountId, payload) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cleanupPvpMemory(now = Date.now()) {
|
||||||
|
for (const [ticketId, ticket] of pvpQueue.entries()) {
|
||||||
|
if (now - ticket.updatedAt > pvpQueueTtlMs) pvpQueue.delete(ticketId)
|
||||||
|
}
|
||||||
|
for (const [matchId, match] of pvpMatches.entries()) {
|
||||||
|
if (now - match.updatedAt > pvpMatchTtlMs) pvpMatches.delete(matchId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validatePvpContentType(value) {
|
||||||
|
if (value !== 'dungeon' && value !== 'raid') {
|
||||||
|
throw new Error('The PvP content type is invalid.')
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
function validatePvpStartStage(value) {
|
||||||
|
const startStage = Number(value)
|
||||||
|
if (!Number.isInteger(startStage) || startStage < 1 || startStage > 1000) {
|
||||||
|
throw new Error('The PvP start stage is invalid.')
|
||||||
|
}
|
||||||
|
return startStage
|
||||||
|
}
|
||||||
|
|
||||||
|
function pvpPlayerInfo(session) {
|
||||||
|
return {
|
||||||
|
accountId: session.accountId,
|
||||||
|
characterId: session.characterId,
|
||||||
|
characterName: session.characterName,
|
||||||
|
className: session.className,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pvpSnapshot(match) {
|
||||||
|
return {
|
||||||
|
id: match.id,
|
||||||
|
contentType: match.contentType,
|
||||||
|
startStage: match.startStage,
|
||||||
|
createdAt: match.createdAt,
|
||||||
|
players: match.players,
|
||||||
|
states: match.states,
|
||||||
|
statuses: match.statuses,
|
||||||
|
progress: match.progress,
|
||||||
|
upgradeChoices: match.upgradeChoices,
|
||||||
|
rematchRequests: match.rematchRequests,
|
||||||
|
updatedAt: match.updatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPvpMatch(contentType, startStage, players, now = Date.now()) {
|
||||||
|
const matchId = randomBytes(12).toString('base64url')
|
||||||
|
const match = {
|
||||||
|
id: matchId,
|
||||||
|
contentType,
|
||||||
|
startStage,
|
||||||
|
createdAt: now,
|
||||||
|
players,
|
||||||
|
states: {},
|
||||||
|
statuses: {},
|
||||||
|
progress: {},
|
||||||
|
upgradeChoices: {},
|
||||||
|
rematchRequests: {},
|
||||||
|
updatedAt: now,
|
||||||
|
}
|
||||||
|
pvpMatches.set(matchId, match)
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
|
||||||
|
function joinPvpQueue(session, payload) {
|
||||||
|
const now = Date.now()
|
||||||
|
cleanupPvpMemory(now)
|
||||||
|
const contentType = validatePvpContentType(payload.contentType)
|
||||||
|
const startStage = validatePvpStartStage(payload.startStage)
|
||||||
|
const existingTicket = [...pvpQueue.values()].find((ticket) =>
|
||||||
|
ticket.accountId === session.accountId
|
||||||
|
&& ticket.characterId === session.characterId
|
||||||
|
&& ticket.contentType === contentType
|
||||||
|
&& ticket.startStage === startStage
|
||||||
|
)
|
||||||
|
if (existingTicket?.matchId) {
|
||||||
|
const match = pvpMatches.get(existingTicket.matchId)
|
||||||
|
if (match) {
|
||||||
|
const side = match.players.a.accountId === session.accountId ? 'a' : 'b'
|
||||||
|
return { ticketId: existingTicket.id, status: 'matched', side, match: pvpSnapshot(match) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const opponent = [...pvpQueue.values()]
|
||||||
|
.filter((ticket) =>
|
||||||
|
!ticket.matchId
|
||||||
|
&& ticket.contentType === contentType
|
||||||
|
&& ticket.startStage === startStage
|
||||||
|
&& ticket.accountId !== session.accountId
|
||||||
|
)
|
||||||
|
.sort((left, right) => left.createdAt - right.createdAt)[0]
|
||||||
|
const player = pvpPlayerInfo(session)
|
||||||
|
if (opponent) {
|
||||||
|
const match = createPvpMatch(contentType, startStage, {
|
||||||
|
a: { side: 'a', ...opponent.player },
|
||||||
|
b: { side: 'b', ...player },
|
||||||
|
}, now)
|
||||||
|
opponent.matchId = match.id
|
||||||
|
opponent.updatedAt = now
|
||||||
|
const ticketId = randomBytes(12).toString('base64url')
|
||||||
|
pvpQueue.set(ticketId, {
|
||||||
|
id: ticketId,
|
||||||
|
accountId: session.accountId,
|
||||||
|
characterId: session.characterId,
|
||||||
|
contentType,
|
||||||
|
startStage,
|
||||||
|
player,
|
||||||
|
matchId: match.id,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
return { ticketId, status: 'matched', side: 'b', match: pvpSnapshot(match) }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingTicket) {
|
||||||
|
existingTicket.updatedAt = now
|
||||||
|
return { ticketId: existingTicket.id, status: 'waiting' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const ticketId = randomBytes(12).toString('base64url')
|
||||||
|
pvpQueue.set(ticketId, {
|
||||||
|
id: ticketId,
|
||||||
|
accountId: session.accountId,
|
||||||
|
characterId: session.characterId,
|
||||||
|
contentType,
|
||||||
|
startStage,
|
||||||
|
player,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
return { ticketId, status: 'waiting' }
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkPvpQueue(session, ticketId) {
|
||||||
|
cleanupPvpMemory()
|
||||||
|
const ticket = pvpQueue.get(ticketId)
|
||||||
|
if (!ticket || ticket.accountId !== session.accountId) {
|
||||||
|
const error = new Error('PvP queue ticket not found.')
|
||||||
|
error.status = 404
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
ticket.updatedAt = Date.now()
|
||||||
|
if (!ticket.matchId) return { ticketId, status: 'waiting' }
|
||||||
|
const match = pvpMatches.get(ticket.matchId)
|
||||||
|
if (!match) return { ticketId, status: 'waiting' }
|
||||||
|
const side = match.players.a.accountId === session.accountId ? 'a' : 'b'
|
||||||
|
return { ticketId, status: 'matched', side, match: pvpSnapshot(match) }
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelPvpQueue(session, ticketId) {
|
||||||
|
const ticket = pvpQueue.get(ticketId)
|
||||||
|
if (ticket && ticket.accountId === session.accountId && !ticket.matchId) {
|
||||||
|
pvpQueue.delete(ticketId)
|
||||||
|
}
|
||||||
|
return { ok: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
function requirePvpMatchForSession(session, matchId) {
|
||||||
|
cleanupPvpMemory()
|
||||||
|
const match = pvpMatches.get(matchId)
|
||||||
|
if (!match) {
|
||||||
|
const error = new Error('PvP match not found.')
|
||||||
|
error.status = 404
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
const side = match.players.a.accountId === session.accountId ? 'a'
|
||||||
|
: match.players.b.accountId === session.accountId ? 'b'
|
||||||
|
: null
|
||||||
|
if (!side) {
|
||||||
|
const error = new Error('That PvP match belongs to another account.')
|
||||||
|
error.status = 403
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
return { match, side }
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePvpMatchState(session, matchId, payload) {
|
||||||
|
const { match, side } = requirePvpMatchForSession(session, matchId)
|
||||||
|
const status = ['playing', 'upgrade-choice', 'won', 'lost'].includes(payload.status)
|
||||||
|
? payload.status
|
||||||
|
: 'playing'
|
||||||
|
const progress = {
|
||||||
|
stage: validatePvpStartStage(payload.stage),
|
||||||
|
encounterIndex: Math.max(0, Math.floor(Number(payload.encounterIndex) || 0)),
|
||||||
|
encountersCleared: Math.max(0, Math.floor(Number(payload.encountersCleared) || 0)),
|
||||||
|
enemyHealth: Math.max(0, Number(payload.enemyHealth) || 0),
|
||||||
|
alive: Boolean(payload.alive),
|
||||||
|
elapsedTicks: Math.max(0, Math.floor(Number(payload.elapsedTicks) || 0)),
|
||||||
|
}
|
||||||
|
match.states[side] = payload.state ?? null
|
||||||
|
match.statuses[side] = status
|
||||||
|
match.progress[side] = progress
|
||||||
|
match.updatedAt = Date.now()
|
||||||
|
return pvpSnapshot(match)
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitPvpUpgradeChoice(session, matchId, payload) {
|
||||||
|
const { match, side } = requirePvpMatchForSession(session, matchId)
|
||||||
|
const encounterIndex = Math.max(0, Math.floor(Number(payload.encounterIndex) || 0))
|
||||||
|
if (!match.upgradeChoices[side]) match.upgradeChoices[side] = {}
|
||||||
|
match.upgradeChoices[side][String(encounterIndex)] = {
|
||||||
|
encounterIndex,
|
||||||
|
buffId: String(payload.buffId ?? ''),
|
||||||
|
debuffId: String(payload.debuffId ?? ''),
|
||||||
|
}
|
||||||
|
match.updatedAt = Date.now()
|
||||||
|
return pvpSnapshot(match)
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestPvpRematch(session, matchId) {
|
||||||
|
const { match, side } = requirePvpMatchForSession(session, matchId)
|
||||||
|
if (match.nextMatchId) {
|
||||||
|
const nextMatch = pvpMatches.get(match.nextMatchId)
|
||||||
|
if (nextMatch) return { status: 'matched', side, match: pvpSnapshot(nextMatch) }
|
||||||
|
}
|
||||||
|
match.rematchRequests = match.rematchRequests ?? {}
|
||||||
|
match.rematchRequests[side] = true
|
||||||
|
match.updatedAt = Date.now()
|
||||||
|
const opponentSide = side === 'a' ? 'b' : 'a'
|
||||||
|
if (!match.rematchRequests[opponentSide]) {
|
||||||
|
return { status: 'waiting', match: pvpSnapshot(match), side }
|
||||||
|
}
|
||||||
|
const nextMatch = createPvpMatch(
|
||||||
|
match.contentType,
|
||||||
|
match.startStage,
|
||||||
|
{
|
||||||
|
a: match.players.a,
|
||||||
|
b: match.players.b,
|
||||||
|
},
|
||||||
|
Date.now(),
|
||||||
|
)
|
||||||
|
match.nextMatchId = nextMatch.id
|
||||||
|
match.updatedAt = Date.now()
|
||||||
|
return { status: 'matched', side, match: pvpSnapshot(nextMatch) }
|
||||||
|
}
|
||||||
|
|
||||||
export function gameApiPlugin() {
|
export function gameApiPlugin() {
|
||||||
return {
|
return {
|
||||||
name: 'ashen-halls-game-api',
|
name: 'ashen-halls-game-api',
|
||||||
@@ -2677,7 +2944,7 @@ export async function handleApiRequest(request, response, next) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const ip = requestIp(request)
|
const ip = requestIp(request)
|
||||||
consumeRateLimit(`api:${ip}`, 240, 60 * 1000)
|
consumeRateLimit(`api:${ip}`, 900, 60 * 1000)
|
||||||
database.prepare(`
|
database.prepare(`
|
||||||
DELETE FROM sessions WHERE expires_at <= CURRENT_TIMESTAMP
|
DELETE FROM sessions WHERE expires_at <= CURRENT_TIMESTAMP
|
||||||
`).run()
|
`).run()
|
||||||
@@ -2686,6 +2953,11 @@ export async function handleApiRequest(request, response, next) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (request.url === '/api/catalog' && request.method === 'GET') {
|
||||||
|
sendJson(response, 200, catalogPayload(getProfile(database, 1)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const session = requireSession(database, request)
|
const session = requireSession(database, request)
|
||||||
|
|
||||||
if (request.url === '/api/profile/sync-save' && request.method === 'GET') {
|
if (request.url === '/api/profile/sync-save' && request.method === 'GET') {
|
||||||
@@ -2711,6 +2983,49 @@ export async function handleApiRequest(request, response, next) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (request.url === '/api/pvp/queue' && request.method === 'POST') {
|
||||||
|
const payload = await readJson(request)
|
||||||
|
sendJson(response, 200, joinPvpQueue(session, payload))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const pvpQueueTicket = request.url.match(/^\/api\/pvp\/queue\/([A-Za-z0-9_-]+)$/)
|
||||||
|
if (pvpQueueTicket && request.method === 'GET') {
|
||||||
|
sendJson(response, 200, checkPvpQueue(session, pvpQueueTicket[1]))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pvpQueueTicket && request.method === 'DELETE') {
|
||||||
|
sendJson(response, 200, cancelPvpQueue(session, pvpQueueTicket[1]))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const pvpMatchState = request.url.match(/^\/api\/pvp\/matches\/([A-Za-z0-9_-]+)\/state$/)
|
||||||
|
if (pvpMatchState && request.method === 'POST') {
|
||||||
|
const payload = await readJson(request, 128 * 1024)
|
||||||
|
sendJson(response, 200, updatePvpMatchState(session, pvpMatchState[1], payload))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const pvpUpgradeChoice = request.url.match(/^\/api\/pvp\/matches\/([A-Za-z0-9_-]+)\/upgrade-choice$/)
|
||||||
|
if (pvpUpgradeChoice && request.method === 'POST') {
|
||||||
|
const payload = await readJson(request)
|
||||||
|
sendJson(response, 200, submitPvpUpgradeChoice(session, pvpUpgradeChoice[1], payload))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const pvpRematch = request.url.match(/^\/api\/pvp\/matches\/([A-Za-z0-9_-]+)\/rematch$/)
|
||||||
|
if (pvpRematch && request.method === 'POST') {
|
||||||
|
sendJson(response, 200, requestPvpRematch(session, pvpRematch[1]))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const pvpMatch = request.url.match(/^\/api\/pvp\/matches\/([A-Za-z0-9_-]+)$/)
|
||||||
|
if (pvpMatch && request.method === 'GET') {
|
||||||
|
sendJson(response, 200, pvpSnapshot(requirePvpMatchForSession(session, pvpMatch[1]).match))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const dungeonCompletion = request.url.match(/^\/api\/dungeons\/(\d+)\/complete$/)
|
const dungeonCompletion = request.url.match(/^\/api\/dungeons\/(\d+)\/complete$/)
|
||||||
if (dungeonCompletion && request.method === 'POST') {
|
if (dungeonCompletion && request.method === 'POST') {
|
||||||
const payload = await readJson(request)
|
const payload = await readJson(request)
|
||||||
|
|||||||
+635
-61
@@ -621,6 +621,7 @@ textarea:focus-visible,
|
|||||||
min-height: 120px;
|
min-height: 120px;
|
||||||
outline: 2px solid #3a3944;
|
outline: 2px solid #3a3944;
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dual-top-member.selected {
|
.dual-top-member.selected {
|
||||||
@@ -773,7 +774,7 @@ textarea:focus-visible,
|
|||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
height: calc(100dvh - 20px);
|
height: calc(100dvh - 20px);
|
||||||
grid-template-rows: auto 1fr;
|
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -803,6 +804,84 @@ textarea:focus-visible,
|
|||||||
outline-color: var(--gold);
|
outline-color: var(--gold);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dual-top-spell-strip {
|
||||||
|
align-items: center;
|
||||||
|
background: var(--panel);
|
||||||
|
border: 3px solid #0c0d11;
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
grid-template-columns: repeat(6, 54px) minmax(180px, 1fr);
|
||||||
|
min-height: 64px;
|
||||||
|
outline: 2px solid var(--edge);
|
||||||
|
padding: 8px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dual-top-spell {
|
||||||
|
align-items: center;
|
||||||
|
background: #20232c;
|
||||||
|
border: 2px solid #08090c;
|
||||||
|
color: var(--ink);
|
||||||
|
display: flex;
|
||||||
|
height: 48px;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 0;
|
||||||
|
outline: 2px solid #4d4c58;
|
||||||
|
padding: 0;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dual-top-spell:not(:disabled) {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dual-top-spell:disabled {
|
||||||
|
opacity: 0.62;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dual-top-spell .spell-icon {
|
||||||
|
font-size: 20px;
|
||||||
|
height: 32px;
|
||||||
|
margin: 0;
|
||||||
|
width: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dual-top-spell > i {
|
||||||
|
background: rgba(0, 0, 0, 0.58);
|
||||||
|
bottom: 0;
|
||||||
|
display: block;
|
||||||
|
left: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dual-top-spell > small {
|
||||||
|
color: #fff4a8;
|
||||||
|
font-family: 'Press Start 2P', monospace;
|
||||||
|
font-size: 10px;
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dual-top-resource {
|
||||||
|
align-self: center;
|
||||||
|
color: #82bfff;
|
||||||
|
font-family: 'Press Start 2P', monospace;
|
||||||
|
font-size: 8px;
|
||||||
|
justify-self: end;
|
||||||
|
min-width: 220px;
|
||||||
|
width: min(280px, 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dual-top-resource strong {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dual-top-resource .bar {
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
.dual-bottom-display {
|
.dual-bottom-display {
|
||||||
background:
|
background:
|
||||||
linear-gradient(rgba(8, 9, 12, 0.91), rgba(8, 9, 12, 0.91)),
|
linear-gradient(rgba(8, 9, 12, 0.91), rgba(8, 9, 12, 0.91)),
|
||||||
@@ -815,6 +894,10 @@ textarea:focus-visible,
|
|||||||
padding: 10px;
|
padding: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pvp-opponent-bottom-display {
|
||||||
|
grid-template-rows: auto auto minmax(0, 1fr) auto;
|
||||||
|
}
|
||||||
|
|
||||||
.dual-controls-header,
|
.dual-controls-header,
|
||||||
.dual-controls-resource,
|
.dual-controls-resource,
|
||||||
.dual-controls-targets,
|
.dual-controls-targets,
|
||||||
@@ -837,6 +920,109 @@ textarea:focus-visible,
|
|||||||
font-size: clamp(14px, 2.2vw, 23px);
|
font-size: clamp(14px, 2.2vw, 23px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dual-controls-header small {
|
||||||
|
color: var(--muted);
|
||||||
|
display: block;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dual-opponent-progress,
|
||||||
|
.dual-opponent-effects {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 3px solid #0c0d11;
|
||||||
|
outline: 2px solid var(--edge);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dual-opponent-progress {
|
||||||
|
align-items: center;
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
grid-template-columns: minmax(130px, 0.45fr) minmax(0, 1fr);
|
||||||
|
padding: 10px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dual-opponent-progress strong {
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 19px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dual-opponent-progress .bar {
|
||||||
|
height: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dual-opponent-party-grid {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 3px solid #0c0d11;
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
min-height: 0;
|
||||||
|
outline: 2px solid var(--edge);
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dual-opponent-party-grid.raid {
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dual-opponent-member {
|
||||||
|
background: var(--panel-light);
|
||||||
|
border: 2px solid #0a0b0e;
|
||||||
|
min-width: 0;
|
||||||
|
outline: 2px solid #3a3944;
|
||||||
|
padding: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dual-opponent-member.dead {
|
||||||
|
filter: grayscale(1);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dual-opponent-member .member-header {
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dual-opponent-member .member-header strong {
|
||||||
|
font-size: 17px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dual-opponent-member .member-header small {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dual-opponent-member .bar {
|
||||||
|
height: 14px;
|
||||||
|
margin-top: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dual-opponent-party-grid.raid .dual-opponent-member {
|
||||||
|
padding: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dual-opponent-party-grid.raid .member-header strong {
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dual-opponent-party-grid.raid .member-header small {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dual-opponent-party-grid.raid .dual-opponent-member .bar {
|
||||||
|
height: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dual-opponent-effects {
|
||||||
|
color: var(--muted);
|
||||||
|
display: grid;
|
||||||
|
font-size: 14px;
|
||||||
|
gap: 8px;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.dual-controls-progress {
|
.dual-controls-progress {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-family: 'Press Start 2P', monospace;
|
font-family: 'Press Start 2P', monospace;
|
||||||
@@ -949,6 +1135,10 @@ textarea:focus-visible,
|
|||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dual-controls-header small {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
.dual-controls-progress {
|
.dual-controls-progress {
|
||||||
font-size: 6px;
|
font-size: 6px;
|
||||||
}
|
}
|
||||||
@@ -1022,6 +1212,67 @@ textarea:focus-visible,
|
|||||||
.dual-controls-spells .spell small {
|
.dual-controls-spells .spell small {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pvp-opponent-bottom-display {
|
||||||
|
grid-template-rows: auto auto minmax(0, 1fr) auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dual-opponent-progress {
|
||||||
|
border-width: 2px;
|
||||||
|
gap: 8px;
|
||||||
|
grid-template-columns: minmax(100px, 0.45fr) minmax(0, 1fr);
|
||||||
|
padding: 6px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dual-opponent-progress .eyebrow {
|
||||||
|
font-size: 6px;
|
||||||
|
margin-bottom: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dual-opponent-progress strong {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dual-opponent-progress .bar {
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dual-opponent-party-grid {
|
||||||
|
border-width: 2px;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dual-opponent-party-grid.raid {
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.dual-opponent-member {
|
||||||
|
padding: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dual-opponent-member .member-header strong {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dual-opponent-member .member-header small {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dual-opponent-member .bar {
|
||||||
|
height: 10px;
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dual-opponent-member .member-effects {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dual-opponent-effects {
|
||||||
|
border-width: 2px;
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.dual-bottom-waiting {
|
.dual-bottom-waiting {
|
||||||
@@ -1685,7 +1936,7 @@ h2 {
|
|||||||
.equipment-screen .crafting-panel,
|
.equipment-screen .crafting-panel,
|
||||||
.talent-screen .talent-tree,
|
.talent-screen .talent-tree,
|
||||||
.talent-screen .spell-effect-layout {
|
.talent-screen .spell-effect-layout {
|
||||||
flex: 1;
|
flex: 0 0 auto;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1712,7 +1963,8 @@ h2 {
|
|||||||
.customize-screen > .embedded-screen {
|
.customize-screen > .embedded-screen {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow-x: hidden;
|
||||||
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.customize-screen .loadout-editor {
|
.customize-screen .loadout-editor {
|
||||||
@@ -2664,10 +2916,15 @@ h2 {
|
|||||||
|
|
||||||
.crafting-layout {
|
.crafting-layout {
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
grid-template-columns: minmax(134px, 0.42fr) minmax(248px, 1fr) minmax(190px, 0.72fr);
|
grid-template-columns: minmax(160px, 1fr) minmax(0, 2fr);
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.crafting-available-panel {
|
||||||
|
gap: 6px;
|
||||||
|
grid-template-columns: minmax(0, 1.25fr) minmax(170px, 0.75fr);
|
||||||
|
}
|
||||||
|
|
||||||
.crafting-filters {
|
.crafting-filters {
|
||||||
gap: 7px;
|
gap: 7px;
|
||||||
}
|
}
|
||||||
@@ -3514,6 +3771,7 @@ h2 {
|
|||||||
|
|
||||||
.spell-effect-layout {
|
.spell-effect-layout {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
flex: 0 0 auto;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
grid-template-columns: 220px minmax(0, 1fr);
|
grid-template-columns: 220px minmax(0, 1fr);
|
||||||
margin-top: 17px;
|
margin-top: 17px;
|
||||||
@@ -3532,6 +3790,7 @@ h2 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.effect-slots-panel {
|
.effect-slots-panel {
|
||||||
|
align-content: start;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
grid-auto-rows: minmax(76px, auto);
|
grid-auto-rows: minmax(76px, auto);
|
||||||
@@ -3635,12 +3894,12 @@ h2 {
|
|||||||
.effect-pool {
|
.effect-pool {
|
||||||
align-content: start;
|
align-content: start;
|
||||||
display: grid;
|
display: grid;
|
||||||
flex: 1;
|
flex: 0 0 auto;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
grid-template-rows: repeat(2, 62px);
|
grid-template-rows: repeat(2, 62px);
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
min-height: 0;
|
min-height: 134px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4154,13 +4413,14 @@ h2 {
|
|||||||
display: grid;
|
display: grid;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
grid-template-columns: minmax(210px, 0.55fr) minmax(360px, 1fr) minmax(320px, 0.85fr);
|
grid-template-columns: minmax(230px, 1fr) minmax(0, 2fr);
|
||||||
margin-top: 13px;
|
margin-top: 13px;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.crafting-filters,
|
.crafting-filters,
|
||||||
|
.crafting-available-panel,
|
||||||
.crafting-list-panel,
|
.crafting-list-panel,
|
||||||
.crafting-detail-panel {
|
.crafting-detail-panel {
|
||||||
background: var(--panel-light);
|
background: var(--panel-light);
|
||||||
@@ -4177,10 +4437,20 @@ h2 {
|
|||||||
gap: 14px;
|
gap: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.crafting-available-panel {
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
grid-template-columns: minmax(360px, 1.05fr) minmax(280px, 0.95fr);
|
||||||
|
outline: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.crafting-filter-grid {
|
.crafting-filter-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 7px;
|
gap: 7px;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
.crafting-filter-grid button,
|
.crafting-filter-grid button,
|
||||||
@@ -4687,7 +4957,7 @@ h2 {
|
|||||||
.customize-tabs {
|
.customize-tabs {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5236,22 +5506,6 @@ h2 {
|
|||||||
top: 0;
|
top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.member-health .health-text {
|
|
||||||
color: var(--ink);
|
|
||||||
font-family: 'Press Start 2P', monospace;
|
|
||||||
font-size: 7px;
|
|
||||||
font-style: normal;
|
|
||||||
left: 50%;
|
|
||||||
line-height: 1;
|
|
||||||
pointer-events: none;
|
|
||||||
position: absolute;
|
|
||||||
text-shadow: 1px 1px #08090c;
|
|
||||||
top: 50%;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
white-space: nowrap;
|
|
||||||
z-index: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.raid-party-grid .party-member {
|
.raid-party-grid .party-member {
|
||||||
min-height: 66px;
|
min-height: 66px;
|
||||||
padding: 7px;
|
padding: 7px;
|
||||||
@@ -5286,6 +5540,7 @@ h2 {
|
|||||||
inset: 0;
|
inset: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
z-index: 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.floating-heal {
|
.floating-heal {
|
||||||
@@ -5633,6 +5888,33 @@ h2 {
|
|||||||
z-index: 10;
|
z-index: 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pvp-round-countdown {
|
||||||
|
align-items: center;
|
||||||
|
background: rgba(5, 5, 8, 0.55);
|
||||||
|
display: flex;
|
||||||
|
inset: 0;
|
||||||
|
justify-content: center;
|
||||||
|
position: fixed;
|
||||||
|
z-index: 9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pvp-round-countdown > div {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 3px solid #0b0c0f;
|
||||||
|
box-shadow: 8px 8px 0 #050507;
|
||||||
|
min-width: 220px;
|
||||||
|
outline: 2px solid var(--gold);
|
||||||
|
padding: 28px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pvp-round-countdown h2 {
|
||||||
|
color: var(--gold);
|
||||||
|
font-size: clamp(48px, 8vw, 92px);
|
||||||
|
line-height: 1;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.result-screen > div,
|
.result-screen > div,
|
||||||
.pause-screen > div {
|
.pause-screen > div {
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
@@ -5832,27 +6114,17 @@ h2 {
|
|||||||
.pvp-board {
|
.pvp-board {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
grid-template-columns: minmax(0, 1fr) minmax(210px, 0.68fr) minmax(0, 1fr);
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
grid-template-rows: minmax(0, 1fr) auto;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pvp-side,
|
.pvp-side {
|
||||||
.pvp-middle-panel {
|
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pvp-vertical-spell-bar,
|
|
||||||
.pvp-vertical-spell-bar.six-slots {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pvp-vertical-spell-bar .spell {
|
|
||||||
min-height: 58px;
|
|
||||||
padding: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pvp-screen-tools {
|
.pvp-screen-tools {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -5863,18 +6135,41 @@ h2 {
|
|||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pvp-resource-wrap {
|
.pvp-side-bars {
|
||||||
color: #82bfff;
|
display: grid;
|
||||||
min-width: 150px;
|
gap: 8px;
|
||||||
text-align: right;
|
min-width: min(320px, 45%);
|
||||||
width: min(170px, 100%);
|
width: min(360px, 48%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pvp-clear-wrap,
|
||||||
|
.pvp-resource-wrap {
|
||||||
|
color: var(--muted);
|
||||||
|
font-family: 'Press Start 2P', monospace;
|
||||||
|
font-size: 7px;
|
||||||
|
text-align: right;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pvp-clear-wrap > span,
|
||||||
.pvp-resource-wrap > span {
|
.pvp-resource-wrap > span {
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pvp-clear-wrap .bar,
|
||||||
|
.pvp-resource-wrap .bar {
|
||||||
|
height: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pvp-clear-wrap {
|
||||||
|
color: #ff8d9a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pvp-resource-wrap {
|
||||||
|
color: #82bfff;
|
||||||
|
}
|
||||||
|
|
||||||
.pvp-side .party-member,
|
.pvp-side .party-member,
|
||||||
.pvp-side .party-member > div,
|
.pvp-side .party-member > div,
|
||||||
.pvp-side .party-member > small {
|
.pvp-side .party-member > small {
|
||||||
@@ -5892,7 +6187,7 @@ h2 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.pvp-side .pvp-party-grid.raid .party-member {
|
.pvp-side .pvp-party-grid.raid .party-member {
|
||||||
min-height: 62px;
|
min-height: 96px;
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5929,6 +6224,29 @@ h2 {
|
|||||||
height: 14px;
|
height: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pvp-side .member-health {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pvp-side .member-health .health-text {
|
||||||
|
align-items: center;
|
||||||
|
color: #fff3c7;
|
||||||
|
display: flex;
|
||||||
|
font-family: 'Press Start 2P', monospace;
|
||||||
|
font-size: 7px;
|
||||||
|
font-style: normal;
|
||||||
|
inset: 0;
|
||||||
|
justify-content: center;
|
||||||
|
pointer-events: none;
|
||||||
|
position: absolute;
|
||||||
|
text-shadow: 1px 1px 0 #08090c, -1px -1px 0 #08090c;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pvp-side .party-member .member-header small {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.pvp-side .member-effects {
|
.pvp-side .member-effects {
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
@@ -5947,26 +6265,61 @@ h2 {
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pvp-middle-panel .encounter-header h2 {
|
|
||||||
font-size: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pvp-middle-panel .encounter-header small,
|
|
||||||
.pvp-enemy-race small {
|
.pvp-enemy-race small {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pvp-middle-panel .roguelike-upgrade-list,
|
|
||||||
.pvp-side .roguelike-upgrade-list {
|
.pvp-side .roguelike-upgrade-list {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 1.1;
|
line-height: 1.1;
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pvp-bottom-spell-bar {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 3px solid #0c0d11;
|
||||||
|
box-shadow: 4px 4px 0 #08090c;
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||||
|
outline: 2px solid var(--edge);
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pvp-bottom-spell-bar .spell {
|
||||||
|
align-items: center;
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
grid-template-columns: auto auto minmax(0, 1fr) auto;
|
||||||
|
min-height: 58px;
|
||||||
|
padding: 7px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pvp-bottom-spell-bar .spell-icon {
|
||||||
|
height: 34px;
|
||||||
|
margin: 0;
|
||||||
|
width: 34px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pvp-bottom-spell-bar .spell strong {
|
||||||
|
font-size: 13px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pvp-bottom-spell-bar .spell small {
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: right;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.pvp-choice-columns {
|
.pvp-choice-columns {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6001,6 +6354,29 @@ h2 {
|
|||||||
margin-top: 8px !important;
|
margin-top: 8px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pvp-upgrade-header {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pvp-upgrade-header h2 {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pvp-upgrade-header > strong {
|
||||||
|
color: var(--gold);
|
||||||
|
font-family: 'Press Start 2P', monospace;
|
||||||
|
font-size: 16px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pvp-upgrade-header > strong.danger {
|
||||||
|
color: #ff8190;
|
||||||
|
}
|
||||||
|
|
||||||
.pvp-upgrade-dialog .pvp-choice-columns {
|
.pvp-upgrade-dialog .pvp-choice-columns {
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
@@ -6573,7 +6949,8 @@ h2 {
|
|||||||
|
|
||||||
.gear-summary,
|
.gear-summary,
|
||||||
.equipment-layout,
|
.equipment-layout,
|
||||||
.crafting-layout {
|
.crafting-layout,
|
||||||
|
.crafting-available-panel {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -7244,9 +7621,203 @@ h2 {
|
|||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-class-layout {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
grid-template-columns: minmax(220px, 0.35fr) minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-class-list {
|
||||||
|
background: #1c1e25;
|
||||||
|
border: 2px solid #090a0d;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
outline: 2px solid #494754;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-class-list button {
|
||||||
|
align-items: center;
|
||||||
|
background: var(--panel-light);
|
||||||
|
border: 2px solid #090a0d;
|
||||||
|
color: var(--ink);
|
||||||
|
cursor: pointer;
|
||||||
|
display: grid;
|
||||||
|
gap: 9px;
|
||||||
|
grid-template-columns: 38px 1fr;
|
||||||
|
min-height: 54px;
|
||||||
|
outline: 2px solid #41404a;
|
||||||
|
padding: 8px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-class-list button.active,
|
||||||
|
.admin-class-list button:hover {
|
||||||
|
outline-color: var(--class-color, var(--gold));
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-class-list button > span,
|
||||||
|
.admin-class-hero > span {
|
||||||
|
align-items: center;
|
||||||
|
background: var(--class-color, var(--gold));
|
||||||
|
color: #111217;
|
||||||
|
display: flex;
|
||||||
|
font-family: 'Press Start 2P', monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
height: 38px;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-class-list strong,
|
||||||
|
.admin-class-list small {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-class-list small {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
margin-top: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-class-detail {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-class-hero {
|
||||||
|
align-items: center;
|
||||||
|
background: var(--panel-light);
|
||||||
|
border: 2px solid #090a0d;
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
grid-template-columns: 54px minmax(180px, auto) minmax(0, 1fr);
|
||||||
|
outline: 2px solid #494754;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-class-hero > span {
|
||||||
|
height: 54px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-class-hero h2 {
|
||||||
|
font-family: 'Press Start 2P', monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-class-hero small,
|
||||||
|
.admin-class-hero p {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-class-table {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-class-table-head,
|
||||||
|
.admin-class-row {
|
||||||
|
align-items: center;
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
grid-template-columns: minmax(230px, 1.5fr) minmax(90px, 0.7fr) minmax(110px, 0.6fr) minmax(65px, 0.45fr) minmax(80px, 0.5fr) minmax(70px, 0.45fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-class-table-head {
|
||||||
|
color: var(--gold);
|
||||||
|
font-family: 'Press Start 2P', monospace;
|
||||||
|
font-size: 7px;
|
||||||
|
padding: 0 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-class-row {
|
||||||
|
background: var(--panel-light);
|
||||||
|
border: 2px solid #090a0d;
|
||||||
|
outline: 2px solid #494754;
|
||||||
|
padding: 9px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-class-row > span {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-class-row > span:first-child {
|
||||||
|
align-items: center;
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
grid-template-columns: 30px minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-class-row i {
|
||||||
|
color: var(--gold);
|
||||||
|
font-style: normal;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-class-row strong,
|
||||||
|
.admin-class-row small {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-class-row small {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 11px;
|
||||||
|
margin-top: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-class-talent-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-class-talent {
|
||||||
|
background: var(--panel-light);
|
||||||
|
border: 2px solid #090a0d;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
outline: 2px solid #494754;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-class-talent > div {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-class-talent > div > span {
|
||||||
|
color: var(--gold);
|
||||||
|
font-family: 'Press Start 2P', monospace;
|
||||||
|
width: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-class-talent small,
|
||||||
|
.admin-class-talent p {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-class-talent em {
|
||||||
|
color: var(--green);
|
||||||
|
font-family: 'Press Start 2P', monospace;
|
||||||
|
font-size: 7px;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 800px) {
|
@media (max-width: 800px) {
|
||||||
.admin-upgrade-toolbar,
|
.admin-upgrade-toolbar,
|
||||||
.admin-upgrade-step {
|
.admin-upgrade-step,
|
||||||
|
.admin-class-layout,
|
||||||
|
.admin-class-hero,
|
||||||
|
.admin-class-table-head,
|
||||||
|
.admin-class-row {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -7678,15 +8249,18 @@ h2 {
|
|||||||
|
|
||||||
.workshop-shell .crafting-layout {
|
.workshop-shell .crafting-layout {
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(150px, 1fr) minmax(0, 2fr);
|
||||||
grid-template-rows: auto minmax(0, 1fr);
|
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workshop-shell .crafting-filters {
|
.workshop-shell .crafting-available-panel {
|
||||||
display: grid;
|
gap: 6px;
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workshop-shell .crafting-filters {
|
||||||
|
display: flex;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.workshop-shell .crafting-filter-grid,
|
.workshop-shell .crafting-filter-grid,
|
||||||
@@ -7695,7 +8269,7 @@ h2 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.workshop-shell .crafting-filter-grid {
|
.workshop-shell .crafting-filter-grid {
|
||||||
grid-template-columns: repeat(10, minmax(0, 1fr));
|
grid-template-columns: minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
.workshop-shell .crafting-filter-grid button {
|
.workshop-shell .crafting-filter-grid button {
|
||||||
@@ -7985,7 +8559,7 @@ h2 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.workshop-shell .crafting-layout {
|
.workshop-shell .crafting-layout {
|
||||||
grid-template-columns: 110px minmax(0, 1fr) 174px;
|
grid-template-columns: 110px minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
.workshop-shell .crafting-filter-grid {
|
.workshop-shell .crafting-filter-grid {
|
||||||
@@ -8189,7 +8763,7 @@ h2 {
|
|||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
.workshop-shell .crafting-filters {
|
.workshop-shell .crafting-available-panel {
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import type { Dispatch, SetStateAction } from 'react'
|
import type { CSSProperties, Dispatch, SetStateAction } from 'react'
|
||||||
|
|
||||||
type AdminItem = {
|
type AdminItem = {
|
||||||
id: number
|
id: number
|
||||||
@@ -76,6 +76,49 @@ type AdminUpgradePath = {
|
|||||||
toItemId: number
|
toItemId: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AdminAbility = {
|
||||||
|
id: number
|
||||||
|
classId: number
|
||||||
|
slug: string
|
||||||
|
name: string
|
||||||
|
spellType: string
|
||||||
|
cost: number
|
||||||
|
cooldown: number
|
||||||
|
power: number
|
||||||
|
unlockLevel: number
|
||||||
|
glyph: string
|
||||||
|
description: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type AdminTalent = {
|
||||||
|
id: number
|
||||||
|
classId: number
|
||||||
|
slug: string
|
||||||
|
name: string
|
||||||
|
maxRank: number
|
||||||
|
tier: number
|
||||||
|
branch: number
|
||||||
|
prerequisiteTalentId: number | null
|
||||||
|
prerequisiteRank: number
|
||||||
|
prerequisiteName: string | null
|
||||||
|
effectType: string
|
||||||
|
effectValuePerRank: number
|
||||||
|
glyph: string
|
||||||
|
description: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type AdminClass = {
|
||||||
|
id: number
|
||||||
|
slug: string
|
||||||
|
name: string
|
||||||
|
resourceName: string
|
||||||
|
maxResource: number
|
||||||
|
themeColor: string
|
||||||
|
description: string
|
||||||
|
abilities: AdminAbility[]
|
||||||
|
talents: AdminTalent[]
|
||||||
|
}
|
||||||
|
|
||||||
type AdminData = {
|
type AdminData = {
|
||||||
items: AdminItem[]
|
items: AdminItem[]
|
||||||
encounters: AdminEncounter[]
|
encounters: AdminEncounter[]
|
||||||
@@ -84,9 +127,10 @@ type AdminData = {
|
|||||||
craftingRecipes: AdminRecipe[]
|
craftingRecipes: AdminRecipe[]
|
||||||
dungeons: AdminDungeon[]
|
dungeons: AdminDungeon[]
|
||||||
gearUpgradePaths: AdminUpgradePath[]
|
gearUpgradePaths: AdminUpgradePath[]
|
||||||
|
classes: AdminClass[]
|
||||||
}
|
}
|
||||||
|
|
||||||
type AdminTab = 'items' | 'dungeons' | 'encounters' | 'loot' | 'crafting' | 'upgrades'
|
type AdminTab = 'items' | 'dungeons' | 'encounters' | 'loot' | 'crafting' | 'upgrades' | 'classes'
|
||||||
type SavingState = Record<string, boolean>
|
type SavingState = Record<string, boolean>
|
||||||
type SetData = Dispatch<SetStateAction<AdminData | null>>
|
type SetData = Dispatch<SetStateAction<AdminData | null>>
|
||||||
type SetSaving = Dispatch<SetStateAction<SavingState>>
|
type SetSaving = Dispatch<SetStateAction<SavingState>>
|
||||||
@@ -99,6 +143,7 @@ const tabs: { id: AdminTab; label: string }[] = [
|
|||||||
{ id: 'loot', label: 'Loot' },
|
{ id: 'loot', label: 'Loot' },
|
||||||
{ id: 'crafting', label: 'Crafting' },
|
{ id: 'crafting', label: 'Crafting' },
|
||||||
{ id: 'upgrades', label: 'Upgrades' },
|
{ id: 'upgrades', label: 'Upgrades' },
|
||||||
|
{ id: 'classes', label: 'Classes' },
|
||||||
]
|
]
|
||||||
|
|
||||||
async function fetchJson<T>(url: string, init?: RequestInit): Promise<T> {
|
async function fetchJson<T>(url: string, init?: RequestInit): Promise<T> {
|
||||||
@@ -143,6 +188,7 @@ export function AdminScreen({ onBack }: { onBack: () => void }) {
|
|||||||
{tab === 'loot' && <LootTab data={data} setData={setData} setSaving={setSaving} saving={saving} />}
|
{tab === 'loot' && <LootTab data={data} setData={setData} setSaving={setSaving} saving={saving} />}
|
||||||
{tab === 'crafting' && <CraftingTab data={data} setData={setData} setSaving={setSaving} saving={saving} />}
|
{tab === 'crafting' && <CraftingTab data={data} setData={setData} setSaving={setSaving} saving={saving} />}
|
||||||
{tab === 'upgrades' && <UpgradesTab data={data} setData={setData} setSaving={setSaving} saving={saving} />}
|
{tab === 'upgrades' && <UpgradesTab data={data} setData={setData} setSaving={setSaving} saving={saving} />}
|
||||||
|
{tab === 'classes' && <ClassesTab data={data} />}
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -830,7 +876,9 @@ function CraftingTab({ data, setData, setSaving, saving }: {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<h3 className="admin-loot-title">Required Components</h3>
|
<h3 className="admin-loot-title">Required Components</h3>
|
||||||
{(!recipe || recipe.components.length === 0) && <p className="admin-empty">No component requirements.</p>}
|
{(!recipe || recipe.components.length === 0) && (
|
||||||
|
<p className="admin-empty">No component requirements. Crafting and upgrades are blocked until materials are added.</p>
|
||||||
|
)}
|
||||||
<div className="admin-loot-list">
|
<div className="admin-loot-list">
|
||||||
{recipe?.components.map((comp) => (
|
{recipe?.components.map((comp) => (
|
||||||
<div key={comp.itemId} className="admin-loot-row">
|
<div key={comp.itemId} className="admin-loot-row">
|
||||||
@@ -981,7 +1029,7 @@ function UpgradesTab({ data, setData, setSaving, saving }: {
|
|||||||
{target
|
{target
|
||||||
? `Target requirements: ${targetRecipe && targetRecipe.components.length > 0
|
? `Target requirements: ${targetRecipe && targetRecipe.components.length > 0
|
||||||
? targetRecipe.components.map((component) => `${component.quantity}x ${itemName(data, component.itemId)}`).join(', ')
|
? targetRecipe.components.map((component) => `${component.quantity}x ${itemName(data, component.itemId)}`).join(', ')
|
||||||
: 'none'}`
|
: 'none configured - upgrade blocked until materials are added'}`
|
||||||
: 'No next upgrade selected.'}
|
: 'No next upgrade selected.'}
|
||||||
</p>
|
</p>
|
||||||
<div className="admin-edit-actions">
|
<div className="admin-edit-actions">
|
||||||
@@ -1015,6 +1063,98 @@ function UpgradesTab({ data, setData, setSaving, saving }: {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ClassesTab({ data }: { data: AdminData }) {
|
||||||
|
const [classId, setClassId] = useState(data.classes[0]?.id ?? 0)
|
||||||
|
const selectedClass = data.classes.find((candidate) => candidate.id === classId)
|
||||||
|
?? data.classes[0]
|
||||||
|
?? null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="admin-panel">
|
||||||
|
<div className="admin-class-layout">
|
||||||
|
<aside className="admin-class-list">
|
||||||
|
<p className="eyebrow">Classes</p>
|
||||||
|
{data.classes.map((gameClass) => (
|
||||||
|
<button
|
||||||
|
className={selectedClass?.id === gameClass.id ? 'active' : ''}
|
||||||
|
key={gameClass.id}
|
||||||
|
onClick={() => setClassId(gameClass.id)}
|
||||||
|
style={{ '--class-color': gameClass.themeColor } as CSSProperties}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span>{gameClass.name[0]}</span>
|
||||||
|
<div>
|
||||||
|
<strong>{gameClass.name}</strong>
|
||||||
|
<small>{gameClass.resourceName} {gameClass.maxResource}</small>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{selectedClass ? (
|
||||||
|
<section className="admin-class-detail">
|
||||||
|
<div className="admin-class-hero" style={{ '--class-color': selectedClass.themeColor } as CSSProperties}>
|
||||||
|
<span>{selectedClass.name[0]}</span>
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">{selectedClass.slug}</p>
|
||||||
|
<h2>{selectedClass.name}</h2>
|
||||||
|
<small>{selectedClass.resourceName} pool: {selectedClass.maxResource}</small>
|
||||||
|
</div>
|
||||||
|
<p>{selectedClass.description}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3 className="admin-loot-title">Abilities ({selectedClass.abilities.length})</h3>
|
||||||
|
<div className="admin-class-table">
|
||||||
|
<div className="admin-class-table-head">
|
||||||
|
<span>Ability</span>
|
||||||
|
<span>Type</span>
|
||||||
|
<span>Default Strength</span>
|
||||||
|
<span>Cost</span>
|
||||||
|
<span>Cooldown</span>
|
||||||
|
<span>Unlock</span>
|
||||||
|
</div>
|
||||||
|
{selectedClass.abilities.map((ability) => (
|
||||||
|
<div key={ability.id} className="admin-class-row">
|
||||||
|
<span><i>{ability.glyph}</i><strong>{ability.name}</strong><small>{ability.description}</small></span>
|
||||||
|
<span>{ability.spellType}</span>
|
||||||
|
<span>{ability.power}</span>
|
||||||
|
<span>{ability.cost}</span>
|
||||||
|
<span>{ability.cooldown}s</span>
|
||||||
|
<span>Lvl {ability.unlockLevel}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3 className="admin-loot-title">Talents ({selectedClass.talents.length})</h3>
|
||||||
|
<div className="admin-class-talent-grid">
|
||||||
|
{selectedClass.talents.map((talent) => (
|
||||||
|
<article key={talent.id} className="admin-class-talent">
|
||||||
|
<div>
|
||||||
|
<span>{talent.glyph}</span>
|
||||||
|
<strong>{talent.name}</strong>
|
||||||
|
</div>
|
||||||
|
<small>Tier {talent.tier} · Branch {talent.branch} · Max {talent.maxRank}</small>
|
||||||
|
<p>{talent.description}</p>
|
||||||
|
<em>{talent.effectType}: {talent.effectValuePerRank}/rank</em>
|
||||||
|
{talent.prerequisiteName && (
|
||||||
|
<small>Requires {talent.prerequisiteName} rank {talent.prerequisiteRank}</small>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
) : (
|
||||||
|
<p className="admin-empty">No classes found.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function jsonRequest(method: 'POST' | 'PUT', body: unknown): RequestInit {
|
function jsonRequest(method: 'POST' | 'PUT', body: unknown): RequestInit {
|
||||||
return {
|
return {
|
||||||
method,
|
method,
|
||||||
|
|||||||
@@ -1375,6 +1375,7 @@ export function CombatScreen({
|
|||||||
encounterIndex,
|
encounterIndex,
|
||||||
encounterCount: encounters.length,
|
encounterCount: encounters.length,
|
||||||
party,
|
party,
|
||||||
|
floatingTexts,
|
||||||
partySize: dungeon.partySize,
|
partySize: dungeon.partySize,
|
||||||
selectedId,
|
selectedId,
|
||||||
log,
|
log,
|
||||||
@@ -1430,6 +1431,7 @@ export function CombatScreen({
|
|||||||
selectedId,
|
selectedId,
|
||||||
spells,
|
spells,
|
||||||
freeCastReady,
|
freeCastReady,
|
||||||
|
floatingTexts,
|
||||||
roguelikeUpgrades,
|
roguelikeUpgrades,
|
||||||
speedMultiplier,
|
speedMultiplier,
|
||||||
status,
|
status,
|
||||||
@@ -1521,7 +1523,6 @@ export function CombatScreen({
|
|||||||
<div className="bar member-health">
|
<div className="bar member-health">
|
||||||
<span style={{ width: `${(member.health / effectiveMaxHealth(member)) * 100}%` }} />
|
<span style={{ width: `${(member.health / effectiveMaxHealth(member)) * 100}%` }} />
|
||||||
{member.shield > 0 && <i style={{ width: `${(member.shield / effectiveMaxHealth(member)) * 100}%` }} />}
|
{member.shield > 0 && <i style={{ width: `${(member.shield / effectiveMaxHealth(member)) * 100}%` }} />}
|
||||||
<em className="health-text">{Math.ceil(member.health)} / {effectiveMaxHealth(member)}</em>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="floating-combat-texts" aria-hidden="true">
|
<div className="floating-combat-texts" aria-hidden="true">
|
||||||
{floatingTexts
|
{floatingTexts
|
||||||
@@ -1595,6 +1596,7 @@ export function CombatScreen({
|
|||||||
{dualScreenEnabled && (
|
{dualScreenEnabled && (
|
||||||
<DualScreenTopCombat
|
<DualScreenTopCombat
|
||||||
state={dualScreenState}
|
state={dualScreenState}
|
||||||
|
onCastSpell={castSpell}
|
||||||
onSelectTarget={setSelectedTargetId}
|
onSelectTarget={setSelectedTargetId}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -596,8 +596,12 @@ export function EquipmentScreen({
|
|||||||
/>
|
/>
|
||||||
<div className="crafting-layout">
|
<div className="crafting-layout">
|
||||||
<aside className="crafting-filters">
|
<aside className="crafting-filters">
|
||||||
|
<EquipmentHeading
|
||||||
|
eyebrow="Slots"
|
||||||
|
title="Gear Slots"
|
||||||
|
detail={slotFilter === 'all' ? 'All' : SLOT_LABELS[slotFilter]}
|
||||||
|
/>
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">Slot</p>
|
|
||||||
<div className="crafting-filter-grid">
|
<div className="crafting-filter-grid">
|
||||||
<button
|
<button
|
||||||
className={slotFilter === 'all' ? 'active' : ''}
|
className={slotFilter === 'all' ? 'active' : ''}
|
||||||
@@ -657,38 +661,39 @@ export function EquipmentScreen({
|
|||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<section className="crafting-list-panel">
|
<section className="crafting-available-panel">
|
||||||
<EquipmentHeading
|
<section className="crafting-list-panel">
|
||||||
eyebrow="Recipes"
|
<EquipmentHeading
|
||||||
title={slotFilter === 'all' ? 'Available' : SLOT_LABELS[slotFilter]}
|
eyebrow="Available Gear"
|
||||||
detail={`Page ${recipePage + 1}/${recipePageCount}`}
|
title={slotFilter === 'all' ? 'Craftable Gear' : SLOT_LABELS[slotFilter]}
|
||||||
/>
|
detail={`Page ${recipePage + 1}/${recipePageCount}`}
|
||||||
{filteredRecipes.length === 0 ? (
|
/>
|
||||||
<p className="inventory-empty">No recipes match filters.</p>
|
{filteredRecipes.length === 0 ? (
|
||||||
) : (
|
<p className="inventory-empty">No recipes match filters.</p>
|
||||||
<div className="crafting-list">
|
) : (
|
||||||
{recipePageItems.map((recipe) => (
|
<div className="crafting-list">
|
||||||
<button
|
{recipePageItems.map((recipe) => (
|
||||||
className={`${selectedRecipeId === recipe.id ? 'selected' : ''} rarity-${recipe.item.rarity}`}
|
<button
|
||||||
key={recipe.id}
|
className={`${selectedRecipeId === recipe.id ? 'selected' : ''} rarity-${recipe.item.rarity}`}
|
||||||
onClick={() => setSelectedRecipeId(recipe.id)}
|
key={recipe.id}
|
||||||
type="button"
|
onClick={() => setSelectedRecipeId(recipe.id)}
|
||||||
>
|
type="button"
|
||||||
<span>{recipe.item.glyph}</span>
|
>
|
||||||
<div>
|
<span>{recipe.item.glyph}</span>
|
||||||
<strong>{recipe.item.name}</strong>
|
<div>
|
||||||
<small>
|
<strong>{recipe.item.name}</strong>
|
||||||
{SLOT_LABELS[recipe.item.slot]} - Item Level {recipe.item.itemLevel}
|
<small>
|
||||||
{recipe.item.setName ? ` - ${recipe.item.setName}` : ''}
|
{SLOT_LABELS[recipe.item.slot]} - Item Level {recipe.item.itemLevel}
|
||||||
</small>
|
{recipe.item.setName ? ` - ${recipe.item.setName}` : ''}
|
||||||
</div>
|
</small>
|
||||||
<i className={recipe.canCraft ? 'ready' : 'missing'}>
|
</div>
|
||||||
{recipe.canCraft ? 'Ready' : 'Needs materials'}
|
<i className={recipe.canCraft ? 'ready' : 'missing'}>
|
||||||
</i>
|
{recipe.canCraft ? 'Ready' : 'Needs materials'}
|
||||||
</button>
|
</i>
|
||||||
))}
|
</button>
|
||||||
</div>
|
))}
|
||||||
)}
|
</div>
|
||||||
|
)}
|
||||||
{filteredRecipes.length > CRAFTING_LIST_PAGE_SIZE && (
|
{filteredRecipes.length > CRAFTING_LIST_PAGE_SIZE && (
|
||||||
<ListPager
|
<ListPager
|
||||||
label={`Page ${recipePage + 1} / ${recipePageCount}`}
|
label={`Page ${recipePage + 1} / ${recipePageCount}`}
|
||||||
@@ -698,42 +703,46 @@ export function EquipmentScreen({
|
|||||||
previousDisabled={recipePage <= 0}
|
previousDisabled={recipePage <= 0}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="crafting-action-row">
|
<div className="crafting-action-row">
|
||||||
<button
|
<button
|
||||||
className="primary-button"
|
className="primary-button"
|
||||||
disabled={selectedRecipeRequiresUpgrade || !selectedRecipe?.canCraft || crafting}
|
disabled={selectedRecipeRequiresUpgrade || !selectedRecipe?.canCraft || crafting}
|
||||||
onClick={craftSelected}
|
onClick={craftSelected}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{crafting ? 'Crafting...' : selectedRecipeRequiresUpgrade ? 'Upgrade Existing Item' : 'Craft Item'}
|
{crafting ? 'Crafting...' : selectedRecipeRequiresUpgrade ? 'Upgrade Existing Item' : 'Craft Item'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="crafting-detail-panel">
|
|
||||||
{selectedRecipe ? (
|
|
||||||
<div className={`crafting-detail rarity-${selectedRecipe.item.rarity}`}>
|
|
||||||
<ItemDetail title="Craft Output" item={{ ...selectedRecipe.item, quantity: 1, equipped: false }} />
|
|
||||||
<div className="crafting-detail-heading">
|
|
||||||
<p className="eyebrow">Materials</p>
|
|
||||||
<span>{selectedRecipe.canCraft ? 'Ready' : 'Missing components'}</span>
|
|
||||||
</div>
|
|
||||||
<div className="crafting-components">
|
|
||||||
{selectedRecipe.components.map((component) => (
|
|
||||||
<div
|
|
||||||
className={component.owned >= component.quantity ? 'ready' : 'missing'}
|
|
||||||
key={component.item.id}
|
|
||||||
>
|
|
||||||
<span>{component.item.glyph}</span>
|
|
||||||
<strong>{component.item.name}</strong>
|
|
||||||
<i>{component.owned}/{component.quantity}</i>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
</section>
|
||||||
<p className="inventory-empty">Select a recipe.</p>
|
|
||||||
)}
|
<section className="crafting-detail-panel">
|
||||||
|
{selectedRecipe ? (
|
||||||
|
<div className={`crafting-detail rarity-${selectedRecipe.item.rarity}`}>
|
||||||
|
<ItemDetail title="Craft Output" item={{ ...selectedRecipe.item, quantity: 1, equipped: false }} />
|
||||||
|
<div className="crafting-detail-heading">
|
||||||
|
<p className="eyebrow">Materials</p>
|
||||||
|
<span>{selectedRecipe.canCraft ? 'Ready' : 'Missing components'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="crafting-components">
|
||||||
|
{selectedRecipe.components.length === 0 && (
|
||||||
|
<p className="inventory-empty">No materials configured. Crafting disabled.</p>
|
||||||
|
)}
|
||||||
|
{selectedRecipe.components.map((component) => (
|
||||||
|
<div
|
||||||
|
className={component.owned >= component.quantity ? 'ready' : 'missing'}
|
||||||
|
key={component.item.id}
|
||||||
|
>
|
||||||
|
<span>{component.item.glyph}</span>
|
||||||
|
<strong>{component.item.name}</strong>
|
||||||
|
<i>{component.owned}/{component.quantity}</i>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="inventory-empty">Select a recipe.</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+104
-5
@@ -39,6 +39,18 @@ export type DualScreenCombatState = {
|
|||||||
encounterIndex: number
|
encounterIndex: number
|
||||||
encounterCount: number
|
encounterCount: number
|
||||||
party: PartyMember[]
|
party: PartyMember[]
|
||||||
|
opponentName?: string
|
||||||
|
opponentClassName?: string
|
||||||
|
opponentParty?: PartyMember[]
|
||||||
|
opponentResource?: number
|
||||||
|
opponentEnemyHealth?: number
|
||||||
|
opponentBuffSummary?: string
|
||||||
|
opponentDebuffSummary?: string
|
||||||
|
floatingTexts: Array<{
|
||||||
|
id: number
|
||||||
|
memberId: string
|
||||||
|
value: number
|
||||||
|
}>
|
||||||
partySize: number
|
partySize: number
|
||||||
selectedId: string
|
selectedId: string
|
||||||
log: CombatLogEntry[]
|
log: CombatLogEntry[]
|
||||||
@@ -426,17 +438,62 @@ export function DualScreenBottomDisplay() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="dual-bottom-display">
|
<main className={`dual-bottom-display ${state.opponentParty ? 'pvp-opponent-bottom-display' : ''}`}>
|
||||||
<header className="dual-controls-header">
|
<header className="dual-controls-header">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">{state.difficultyName} {state.contentName}</p>
|
<p className="eyebrow">{state.opponentParty ? 'Opponent View' : `${state.difficultyName} ${state.contentName}`}</p>
|
||||||
<h1>{state.dungeonName}</h1>
|
<h1>{state.opponentParty ? state.opponentName : state.dungeonName}</h1>
|
||||||
|
{state.opponentParty && <small>{state.opponentClassName}</small>}
|
||||||
</div>
|
</div>
|
||||||
<div className="dual-controls-progress">
|
<div className="dual-controls-progress">
|
||||||
<span>Encounter {state.encounterIndex + 1}/{state.encounterCount}</span>
|
<span>{state.opponentParty ? 'Live PvP' : `Encounter ${state.encounterIndex + 1}/${state.encounterCount}`}</span>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
{state.opponentParty ? (
|
||||||
|
<>
|
||||||
|
<section className="dual-opponent-progress">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Opponent Clear</p>
|
||||||
|
<strong>{Math.max(0, Math.floor(state.opponentEnemyHealth ?? 0))} / {state.encounterMaxHealth}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="bar enemy-health boss-bar">
|
||||||
|
<span style={{ width: `${Math.max(0, ((state.opponentEnemyHealth ?? 0) / state.encounterMaxHealth) * 100)}%` }} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className={`dual-opponent-party-grid ${state.opponentParty.length > 6 ? 'raid' : ''}`}>
|
||||||
|
{state.opponentParty.map((member) => (
|
||||||
|
<article className={`dual-opponent-member ${member.health <= 0 ? 'dead' : ''}`} key={member.id}>
|
||||||
|
<div className="member-header">
|
||||||
|
<span className={`role role-${member.role.toLowerCase()}`}>{member.role[0]}</span>
|
||||||
|
<strong>{member.name}</strong>
|
||||||
|
<small>{Math.ceil(member.health)} / {member.maxHealth}</small>
|
||||||
|
</div>
|
||||||
|
<div className="bar member-health">
|
||||||
|
<span style={{ width: `${(member.health / member.maxHealth) * 100}%` }} />
|
||||||
|
{member.shield > 0 && (
|
||||||
|
<i style={{ width: `${(member.shield / member.maxHealth) * 100}%` }} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="member-effects">
|
||||||
|
{memberHotEffects(member).map((effect) => (
|
||||||
|
<span className="buff" key={effect.id}>{effect.label}</span>
|
||||||
|
))}
|
||||||
|
{member.debuff && <span className="debuff">{member.debuff}</span>}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="dual-opponent-effects">
|
||||||
|
<span>Buffs: {state.opponentBuffSummary || 'none'}</span>
|
||||||
|
<span>Debuffs: {state.opponentDebuffSummary || 'none'}</span>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
|
||||||
<section className="dual-controls-resource">
|
<section className="dual-controls-resource">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">Active Target</p>
|
<p className="eyebrow">Active Target</p>
|
||||||
@@ -537,6 +594,8 @@ export function DualScreenBottomDisplay() {
|
|||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</section>
|
</section>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -544,9 +603,11 @@ export function DualScreenBottomDisplay() {
|
|||||||
export function DualScreenTopCombat({
|
export function DualScreenTopCombat({
|
||||||
state,
|
state,
|
||||||
onSelectTarget,
|
onSelectTarget,
|
||||||
|
onCastSpell,
|
||||||
}: {
|
}: {
|
||||||
state: DualScreenCombatState
|
state: DualScreenCombatState
|
||||||
onSelectTarget: (id: string) => void
|
onSelectTarget: (id: string) => void
|
||||||
|
onCastSpell?: (spell: Spell) => void
|
||||||
}) {
|
}) {
|
||||||
const enemyPercent = Math.max(
|
const enemyPercent = Math.max(
|
||||||
0,
|
0,
|
||||||
@@ -597,7 +658,11 @@ export function DualScreenTopCombat({
|
|||||||
{member.shield > 0 && (
|
{member.shield > 0 && (
|
||||||
<i style={{ width: `${(member.shield / member.maxHealth) * 100}%` }} />
|
<i style={{ width: `${(member.shield / member.maxHealth) * 100}%` }} />
|
||||||
)}
|
)}
|
||||||
<em className="health-text">{Math.ceil(member.health)} / {member.maxHealth}</em>
|
</div>
|
||||||
|
<div className="floating-combat-texts" aria-hidden="true">
|
||||||
|
{state.floatingTexts
|
||||||
|
.filter((entry) => entry.memberId === member.id)
|
||||||
|
.map((entry) => <span className="floating-heal" key={entry.id}>+{entry.value}</span>)}
|
||||||
</div>
|
</div>
|
||||||
{state.directPartyTargeting && targetBinding && (
|
{state.directPartyTargeting && targetBinding && (
|
||||||
<div className="member-target-key">
|
<div className="member-target-key">
|
||||||
@@ -619,6 +684,40 @@ export function DualScreenTopCombat({
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section className="dual-top-spell-strip">
|
||||||
|
{state.spells.map((spell, slotIndex) => {
|
||||||
|
if (!spell) return <div className="dual-top-spell empty" key={`empty-${slotIndex}`} />
|
||||||
|
const percent = spell.remaining > 0
|
||||||
|
? Math.min(100, (spell.remaining / Math.max(1, spell.cooldown)) * 100)
|
||||||
|
: 0
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className="dual-top-spell"
|
||||||
|
disabled={
|
||||||
|
!state.playerIsAlive
|
||||||
|
|| state.resource < spell.cost
|
||||||
|
|| spell.remaining > 0
|
||||||
|
|| state.status !== 'playing'
|
||||||
|
|| state.paused
|
||||||
|
}
|
||||||
|
key={spell.id}
|
||||||
|
onClick={() => onCastSpell?.(spell)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span className={`spell-icon spell-${spell.kind}`}>{spell.glyph}</span>
|
||||||
|
{spell.remaining > 0 && <i style={{ height: `${percent}%` }} />}
|
||||||
|
{spell.remaining > 0 && <small>{spell.remaining.toFixed(0)}</small>}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
<div className="dual-top-resource">
|
||||||
|
<strong>{state.resourceName} {Math.floor(state.resource)} / {state.maxResource}</strong>
|
||||||
|
<div className="bar mana-bar">
|
||||||
|
<span style={{ width: `${(state.resource / state.maxResource) * 100}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+115
-7
@@ -1,4 +1,5 @@
|
|||||||
import starterProfile from './offline-starter-profile.json'
|
import starterProfile from './offline-starter-profile.json'
|
||||||
|
import { bundledCatalogHash } from './offline-catalog-meta'
|
||||||
import type {
|
import type {
|
||||||
Account,
|
Account,
|
||||||
AuthSession,
|
AuthSession,
|
||||||
@@ -36,7 +37,8 @@ export interface GameRepository {
|
|||||||
durationSeconds: number,
|
durationSeconds: number,
|
||||||
options?: {
|
options?: {
|
||||||
bossesCleared?: number
|
bossesCleared?: number
|
||||||
experienceMode?: 'default' | 'pvp-boss-quarter-level'
|
experienceMode?: 'default' | 'pvp-boss-quarter-level' | 'pvp-fight-twelfth-level'
|
||||||
|
fightsCleared?: number
|
||||||
lootSourceEncounterId?: number
|
lootSourceEncounterId?: number
|
||||||
roguelikeStage?: number
|
roguelikeStage?: number
|
||||||
},
|
},
|
||||||
@@ -82,6 +84,12 @@ type OnlineCache = {
|
|||||||
dirty: boolean
|
dirty: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CatalogCache = {
|
||||||
|
version: 1
|
||||||
|
hash: string
|
||||||
|
profile: CharacterProfile
|
||||||
|
}
|
||||||
|
|
||||||
export type CloudSyncStatus = {
|
export type CloudSyncStatus = {
|
||||||
available: boolean
|
available: boolean
|
||||||
dirty: boolean
|
dirty: boolean
|
||||||
@@ -102,6 +110,8 @@ type LocalSaveStore = {
|
|||||||
const modeKey = 'chronicle.repositoryMode'
|
const modeKey = 'chronicle.repositoryMode'
|
||||||
const offlineSaveKey = 'chronicle.offlineSave.v1'
|
const offlineSaveKey = 'chronicle.offlineSave.v1'
|
||||||
const onlineCacheKey = 'chronicle.onlineCache.v1'
|
const onlineCacheKey = 'chronicle.onlineCache.v1'
|
||||||
|
const catalogCacheKey = 'chronicle.catalog.v1'
|
||||||
|
const catalogBundleKey = 'chronicle.catalog.bundleHash.v1'
|
||||||
const authTokenKey = 'chronicle.authToken.v1'
|
const authTokenKey = 'chronicle.authToken.v1'
|
||||||
const offlineAccount = { id: -1, username: 'Offline' }
|
const offlineAccount = { id: -1, username: 'Offline' }
|
||||||
const ABILITY_SLOT_COUNT = 6
|
const ABILITY_SLOT_COUNT = 6
|
||||||
@@ -281,8 +291,42 @@ function clearOnlineCache() {
|
|||||||
localStorage.removeItem(onlineCacheKey)
|
localStorage.removeItem(onlineCacheKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function bundledCatalog(): CatalogCache {
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
hash: bundledCatalogHash,
|
||||||
|
profile: starterProfile as CharacterProfile,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readCatalogCache(): CatalogCache | null {
|
||||||
|
if (localStorage.getItem(catalogBundleKey) !== bundledCatalogHash) {
|
||||||
|
localStorage.removeItem(catalogCacheKey)
|
||||||
|
localStorage.setItem(catalogBundleKey, bundledCatalogHash)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const serialized = localStorage.getItem(catalogCacheKey)
|
||||||
|
if (!serialized) return null
|
||||||
|
try {
|
||||||
|
const raw = JSON.parse(serialized) as CatalogCache
|
||||||
|
if (raw.version !== 1 || typeof raw.hash !== 'string' || !raw.profile) return null
|
||||||
|
return raw
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeCatalogCache(cache: CatalogCache) {
|
||||||
|
localStorage.setItem(catalogBundleKey, bundledCatalogHash)
|
||||||
|
localStorage.setItem(catalogCacheKey, JSON.stringify(cache))
|
||||||
|
}
|
||||||
|
|
||||||
|
function activeCatalog(): CatalogCache {
|
||||||
|
return readCatalogCache() ?? bundledCatalog()
|
||||||
|
}
|
||||||
|
|
||||||
function buildProfile(save: OfflineSave): CharacterProfile {
|
function buildProfile(save: OfflineSave): CharacterProfile {
|
||||||
const static_ = clone(starterProfile) as CharacterProfile
|
const static_ = clone(activeCatalog().profile)
|
||||||
const cd = save.characters[save.activeClassId]
|
const cd = save.characters[save.activeClassId]
|
||||||
const gameClass = static_.classes.find((c) => c.id === save.activeClassId)!
|
const gameClass = static_.classes.find((c) => c.id === save.activeClassId)!
|
||||||
|
|
||||||
@@ -364,10 +408,13 @@ function updateCraftingRecipes(profile: CharacterProfile) {
|
|||||||
...component,
|
...component,
|
||||||
owned: owned.get(component.item.id) ?? 0,
|
owned: owned.get(component.item.id) ?? 0,
|
||||||
}))
|
}))
|
||||||
|
const hasRequiredComponents = components.length > 0
|
||||||
|
&& components.every((component) => component.quantity > 0)
|
||||||
return {
|
return {
|
||||||
...recipe,
|
...recipe,
|
||||||
components,
|
components,
|
||||||
canCraft: components.every((component) => component.owned >= component.quantity),
|
canCraft: hasRequiredComponents
|
||||||
|
&& components.every((component) => component.owned >= component.quantity),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -457,6 +504,31 @@ function scaledPvpBossExperience(
|
|||||||
return { experience, level }
|
return { experience, level }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function scaledPvpFightExperience(
|
||||||
|
startingExperience: number,
|
||||||
|
startingLevel: number,
|
||||||
|
fightsCleared: number,
|
||||||
|
maxLevel: number,
|
||||||
|
targetLevel = startingLevel,
|
||||||
|
) {
|
||||||
|
let experience = startingExperience
|
||||||
|
let level = startingLevel
|
||||||
|
const maxExperience = experienceForLevel(maxLevel)
|
||||||
|
for (let fightIndex = 0; fightIndex < fightsCleared && experience < maxExperience; fightIndex += 1) {
|
||||||
|
const currentLevelFloor = experienceForLevel(level)
|
||||||
|
const nextLevelExperience = level >= maxLevel
|
||||||
|
? maxExperience
|
||||||
|
: experienceForLevel(level + 1)
|
||||||
|
const levelBand = Math.max(1, nextLevelExperience - currentLevelFloor)
|
||||||
|
const rewardRate = targetLevel > level ? 1 / 6 : 1 / 12
|
||||||
|
experience = Math.min(maxExperience, experience + Math.round(levelBand * rewardRate))
|
||||||
|
while (level < maxLevel && experienceForLevel(level + 1) <= experience) {
|
||||||
|
level += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { experience, level }
|
||||||
|
}
|
||||||
|
|
||||||
function talentEffectCapacity(level: number) {
|
function talentEffectCapacity(level: number) {
|
||||||
return Math.min(4, Math.max(0, Math.floor(level / 5)))
|
return Math.min(4, Math.max(0, Math.floor(level / 5)))
|
||||||
}
|
}
|
||||||
@@ -625,7 +697,7 @@ function getApiBaseUrl(path: string): string {
|
|||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
async function requestJson<T>(path: string, init?: RequestInit): Promise<T> {
|
export async function requestGameApiJson<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
const baseUrl = getApiBaseUrl(path)
|
const baseUrl = getApiBaseUrl(path)
|
||||||
const url = baseUrl ? `${baseUrl}${path}` : path
|
const url = baseUrl ? `${baseUrl}${path}` : path
|
||||||
const headers = new Headers(init?.headers)
|
const headers = new Headers(init?.headers)
|
||||||
@@ -650,10 +722,31 @@ async function requestJson<T>(path: string, init?: RequestInit): Promise<T> {
|
|||||||
return body
|
return body
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function requestJson<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
return requestGameApiJson(path, init)
|
||||||
|
}
|
||||||
|
|
||||||
function isNetworkError(reason: unknown): reason is NetworkError {
|
function isNetworkError(reason: unknown): reason is NetworkError {
|
||||||
return reason instanceof Error && Boolean((reason as NetworkError).network)
|
return reason instanceof Error && Boolean((reason as NetworkError).network)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadServerCatalog(): Promise<CatalogCache> {
|
||||||
|
return requestJson('/api/catalog')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshCatalogFromServer(): Promise<CatalogCache | null> {
|
||||||
|
try {
|
||||||
|
const catalog = await loadServerCatalog()
|
||||||
|
if (catalog.version !== 1 || !catalog.hash || !catalog.profile) return null
|
||||||
|
if (catalog.hash !== activeCatalog().hash || !readCatalogCache()) {
|
||||||
|
writeCatalogCache(catalog)
|
||||||
|
}
|
||||||
|
return catalog
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function cachedOnlineSession(): AuthSession | null {
|
function cachedOnlineSession(): AuthSession | null {
|
||||||
const cache = readOnlineCache()
|
const cache = readOnlineCache()
|
||||||
if (!cache) return null
|
if (!cache) return null
|
||||||
@@ -695,6 +788,7 @@ async function pushServerSyncSave(save: OfflineSave): Promise<{ profile: Charact
|
|||||||
async function finalizeOnlineSession(session: AuthSession): Promise<AuthSession> {
|
async function finalizeOnlineSession(session: AuthSession): Promise<AuthSession> {
|
||||||
const cache = readOnlineCache()
|
const cache = readOnlineCache()
|
||||||
if (session.token) writeAuthToken(session.token)
|
if (session.token) writeAuthToken(session.token)
|
||||||
|
await refreshCatalogFromServer()
|
||||||
if (!session.account || !session.profile) {
|
if (!session.account || !session.profile) {
|
||||||
if (session.account && cache?.account.id === session.account.id) {
|
if (session.account && cache?.account.id === session.account.id) {
|
||||||
return {
|
return {
|
||||||
@@ -845,7 +939,7 @@ const serverRepository: GameRepository = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function emptyCharacterData(classId: number): CharacterData {
|
function emptyCharacterData(classId: number): CharacterData {
|
||||||
const static_ = clone(starterProfile) as CharacterProfile
|
const static_ = clone(activeCatalog().profile)
|
||||||
const gc = static_.classes.find((c) => c.id === classId)!
|
const gc = static_.classes.find((c) => c.id === classId)!
|
||||||
const talentRanks: Record<string, number> = {}
|
const talentRanks: Record<string, number> = {}
|
||||||
for (const t of gc.talents) talentRanks[String(t.id)] = 0
|
for (const t of gc.talents) talentRanks[String(t.id)] = 0
|
||||||
@@ -1074,7 +1168,15 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
|
|||||||
const bossesCleared = Math.max(0, Math.floor(options?.bossesCleared ?? encountersCleared / 3))
|
const bossesCleared = Math.max(0, Math.floor(options?.bossesCleared ?? encountersCleared / 3))
|
||||||
const scaledReward = options?.experienceMode === 'pvp-boss-quarter-level'
|
const scaledReward = options?.experienceMode === 'pvp-boss-quarter-level'
|
||||||
? scaledPvpBossExperience(previousExperience, previousLevel, bossesCleared, profile.maxLevel, highestOtherClassLevel(save))
|
? scaledPvpBossExperience(previousExperience, previousLevel, bossesCleared, profile.maxLevel, highestOtherClassLevel(save))
|
||||||
: null
|
: options?.experienceMode === 'pvp-fight-twelfth-level'
|
||||||
|
? scaledPvpFightExperience(
|
||||||
|
previousExperience,
|
||||||
|
previousLevel,
|
||||||
|
Math.max(0, Math.floor(options.fightsCleared ?? encountersCleared)),
|
||||||
|
profile.maxLevel,
|
||||||
|
highestOtherClassLevel(save),
|
||||||
|
)
|
||||||
|
: null
|
||||||
const baseRoguelikeReward = Math.round(dungeon.experienceReward * difficulty.experienceMultiplier * (encountersCleared / 3))
|
const baseRoguelikeReward = Math.round(dungeon.experienceReward * difficulty.experienceMultiplier * (encountersCleared / 3))
|
||||||
const newExperience = scaledReward
|
const newExperience = scaledReward
|
||||||
? scaledReward.experience
|
? scaledReward.experience
|
||||||
@@ -1301,12 +1403,14 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
|
|||||||
if (!recipe) throw new Error('That crafting recipe does not exist.')
|
if (!recipe) throw new Error('That crafting recipe does not exist.')
|
||||||
const requiresUpgrade = !DIRECT_CRAFT_ITEM_LEVELS.has(recipe.item.itemLevel)
|
const requiresUpgrade = !DIRECT_CRAFT_ITEM_LEVELS.has(recipe.item.itemLevel)
|
||||||
if (requiresUpgrade) throw new Error('Upgrade the previous item tier instead.')
|
if (requiresUpgrade) throw new Error('Upgrade the previous item tier instead.')
|
||||||
|
if (recipe.components.length === 0) throw new Error('That recipe has no component requirements.')
|
||||||
const missing = recipe.components.find((component) => component.owned < component.quantity)
|
const missing = recipe.components.find((component) => component.owned < component.quantity)
|
||||||
if (missing) {
|
if (missing) {
|
||||||
throw new Error(`Need ${missing.quantity} ${missing.item.name} to craft this item.`)
|
throw new Error(`Need ${missing.quantity} ${missing.item.name} to craft this item.`)
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const component of recipe.components) {
|
for (const component of recipe.components) {
|
||||||
|
if (component.quantity <= 0) throw new Error('Recipe components must require at least one item.')
|
||||||
const owned = profile.inventory.find((candidate) => candidate.id === component.item.id)
|
const owned = profile.inventory.find((candidate) => candidate.id === component.item.id)
|
||||||
if (!owned) throw new Error(`Need ${component.quantity} ${component.item.name} to craft this item.`)
|
if (!owned) throw new Error(`Need ${component.quantity} ${component.item.name} to craft this item.`)
|
||||||
owned.quantity -= component.quantity
|
owned.quantity -= component.quantity
|
||||||
@@ -1331,12 +1435,14 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
|
|||||||
? selectUpgradeRecipe(profile.gearUpgradePaths ?? [], profile.craftingRecipes, item)
|
? selectUpgradeRecipe(profile.gearUpgradePaths ?? [], profile.craftingRecipes, item)
|
||||||
: null
|
: null
|
||||||
if (!targetRecipe) throw new Error('No upgrade is available for this item.')
|
if (!targetRecipe) throw new Error('No upgrade is available for this item.')
|
||||||
|
if (targetRecipe.components.length === 0) throw new Error('That upgrade has no component requirements.')
|
||||||
const missing = targetRecipe.components.find((component) => component.owned < component.quantity)
|
const missing = targetRecipe.components.find((component) => component.owned < component.quantity)
|
||||||
if (missing) {
|
if (missing) {
|
||||||
throw new Error(`Need ${missing.quantity} ${missing.item.name} to upgrade this item.`)
|
throw new Error(`Need ${missing.quantity} ${missing.item.name} to upgrade this item.`)
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const component of targetRecipe.components) {
|
for (const component of targetRecipe.components) {
|
||||||
|
if (component.quantity <= 0) throw new Error('Upgrade components must require at least one item.')
|
||||||
const owned = profile.inventory.find((candidate) => candidate.id === component.item.id)
|
const owned = profile.inventory.find((candidate) => candidate.id === component.item.id)
|
||||||
if (!owned) throw new Error(`Need ${component.quantity} ${component.item.name} to upgrade this item.`)
|
if (!owned) throw new Error(`Need ${component.quantity} ${component.item.name} to upgrade this item.`)
|
||||||
owned.quantity -= component.quantity
|
owned.quantity -= component.quantity
|
||||||
@@ -1537,7 +1643,9 @@ export async function syncCloudSave(): Promise<CharacterProfile> {
|
|||||||
if (!cache) {
|
if (!cache) {
|
||||||
throw new Error('No signed-in save is available for cloud sync.')
|
throw new Error('No signed-in save is available for cloud sync.')
|
||||||
}
|
}
|
||||||
|
await refreshCatalogFromServer()
|
||||||
const synced = await pushServerSyncSave(cache.save)
|
const synced = await pushServerSyncSave(cache.save)
|
||||||
|
await refreshCatalogFromServer()
|
||||||
writeOnlineCache({
|
writeOnlineCache({
|
||||||
version: 1,
|
version: 1,
|
||||||
account: cache.account,
|
account: cache.account,
|
||||||
@@ -1545,7 +1653,7 @@ export async function syncCloudSave(): Promise<CharacterProfile> {
|
|||||||
dirty: false,
|
dirty: false,
|
||||||
})
|
})
|
||||||
writeMode('online')
|
writeMode('online')
|
||||||
return synced.profile
|
return buildProfile(synced.save)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function selectOnlineMode() {
|
export function selectOnlineMode() {
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export const bundledCatalogHash = '07506c52bab428c439f09a9b82e39e6eff2b243fb972d664da48852059bcd937'
|
||||||
@@ -1437,6 +1437,22 @@
|
|||||||
"setName": null
|
"setName": null
|
||||||
},
|
},
|
||||||
"components": [
|
"components": [
|
||||||
|
{
|
||||||
|
"item": {
|
||||||
|
"id": 383002,
|
||||||
|
"slug": "bulldrome-boss-coin-diff-2-ilvl-10",
|
||||||
|
"name": "Green Bulldrome Coin",
|
||||||
|
"slot": "component",
|
||||||
|
"rarity": "uncommon",
|
||||||
|
"itemLevel": 10,
|
||||||
|
"healingPower": 0,
|
||||||
|
"maxResourceBonus": 0,
|
||||||
|
"glyph": "$",
|
||||||
|
"description": "A boss coin from Bulldrome used for item level 10 crafting."
|
||||||
|
},
|
||||||
|
"quantity": 5,
|
||||||
|
"owned": 0
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"item": {
|
"item": {
|
||||||
"id": 683002,
|
"id": 683002,
|
||||||
@@ -1450,7 +1466,7 @@
|
|||||||
"glyph": "$",
|
"glyph": "$",
|
||||||
"description": "A boss coin from Tigrex used for item level 10 crafting."
|
"description": "A boss coin from Tigrex used for item level 10 crafting."
|
||||||
},
|
},
|
||||||
"quantity": 10,
|
"quantity": 5,
|
||||||
"owned": 0
|
"owned": 0
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -1477,6 +1493,22 @@
|
|||||||
"setName": null
|
"setName": null
|
||||||
},
|
},
|
||||||
"components": [
|
"components": [
|
||||||
|
{
|
||||||
|
"item": {
|
||||||
|
"id": 383002,
|
||||||
|
"slug": "bulldrome-boss-coin-diff-2-ilvl-10",
|
||||||
|
"name": "Green Bulldrome Coin",
|
||||||
|
"slot": "component",
|
||||||
|
"rarity": "uncommon",
|
||||||
|
"itemLevel": 10,
|
||||||
|
"healingPower": 0,
|
||||||
|
"maxResourceBonus": 0,
|
||||||
|
"glyph": "$",
|
||||||
|
"description": "A boss coin from Bulldrome used for item level 10 crafting."
|
||||||
|
},
|
||||||
|
"quantity": 5,
|
||||||
|
"owned": 0
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"item": {
|
"item": {
|
||||||
"id": 683002,
|
"id": 683002,
|
||||||
@@ -1490,7 +1522,7 @@
|
|||||||
"glyph": "$",
|
"glyph": "$",
|
||||||
"description": "A boss coin from Tigrex used for item level 10 crafting."
|
"description": "A boss coin from Tigrex used for item level 10 crafting."
|
||||||
},
|
},
|
||||||
"quantity": 10,
|
"quantity": 5,
|
||||||
"owned": 0
|
"owned": 0
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -1517,6 +1549,22 @@
|
|||||||
"setName": null
|
"setName": null
|
||||||
},
|
},
|
||||||
"components": [
|
"components": [
|
||||||
|
{
|
||||||
|
"item": {
|
||||||
|
"id": 383002,
|
||||||
|
"slug": "bulldrome-boss-coin-diff-2-ilvl-10",
|
||||||
|
"name": "Green Bulldrome Coin",
|
||||||
|
"slot": "component",
|
||||||
|
"rarity": "uncommon",
|
||||||
|
"itemLevel": 10,
|
||||||
|
"healingPower": 0,
|
||||||
|
"maxResourceBonus": 0,
|
||||||
|
"glyph": "$",
|
||||||
|
"description": "A boss coin from Bulldrome used for item level 10 crafting."
|
||||||
|
},
|
||||||
|
"quantity": 5,
|
||||||
|
"owned": 0
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"item": {
|
"item": {
|
||||||
"id": 683002,
|
"id": 683002,
|
||||||
@@ -1530,7 +1578,7 @@
|
|||||||
"glyph": "$",
|
"glyph": "$",
|
||||||
"description": "A boss coin from Tigrex used for item level 10 crafting."
|
"description": "A boss coin from Tigrex used for item level 10 crafting."
|
||||||
},
|
},
|
||||||
"quantity": 10,
|
"quantity": 5,
|
||||||
"owned": 0
|
"owned": 0
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -1797,7 +1845,7 @@
|
|||||||
"setName": null
|
"setName": null
|
||||||
},
|
},
|
||||||
"components": [],
|
"components": [],
|
||||||
"canCraft": true
|
"canCraft": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 1203,
|
"id": 1203,
|
||||||
@@ -1820,7 +1868,7 @@
|
|||||||
"setName": null
|
"setName": null
|
||||||
},
|
},
|
||||||
"components": [],
|
"components": [],
|
||||||
"canCraft": true
|
"canCraft": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 1201,
|
"id": 1201,
|
||||||
@@ -1843,7 +1891,7 @@
|
|||||||
"setName": null
|
"setName": null
|
||||||
},
|
},
|
||||||
"components": [],
|
"components": [],
|
||||||
"canCraft": true
|
"canCraft": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 1204,
|
"id": 1204,
|
||||||
@@ -1866,7 +1914,7 @@
|
|||||||
"setName": null
|
"setName": null
|
||||||
},
|
},
|
||||||
"components": [],
|
"components": [],
|
||||||
"canCraft": true
|
"canCraft": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 1205,
|
"id": 1205,
|
||||||
@@ -1889,7 +1937,7 @@
|
|||||||
"setName": null
|
"setName": null
|
||||||
},
|
},
|
||||||
"components": [],
|
"components": [],
|
||||||
"canCraft": true
|
"canCraft": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 1206,
|
"id": 1206,
|
||||||
@@ -1912,7 +1960,7 @@
|
|||||||
"setName": null
|
"setName": null
|
||||||
},
|
},
|
||||||
"components": [],
|
"components": [],
|
||||||
"canCraft": true
|
"canCraft": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 1209,
|
"id": 1209,
|
||||||
@@ -1935,7 +1983,7 @@
|
|||||||
"setName": null
|
"setName": null
|
||||||
},
|
},
|
||||||
"components": [],
|
"components": [],
|
||||||
"canCraft": true
|
"canCraft": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 1208,
|
"id": 1208,
|
||||||
@@ -1958,7 +2006,7 @@
|
|||||||
"setName": null
|
"setName": null
|
||||||
},
|
},
|
||||||
"components": [],
|
"components": [],
|
||||||
"canCraft": true
|
"canCraft": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 1207,
|
"id": 1207,
|
||||||
@@ -1981,7 +2029,7 @@
|
|||||||
"setName": null
|
"setName": null
|
||||||
},
|
},
|
||||||
"components": [],
|
"components": [],
|
||||||
"canCraft": true
|
"canCraft": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 1302,
|
"id": 1302,
|
||||||
|
|||||||
+2
-1
@@ -349,7 +349,8 @@ export async function completeRoguelike(
|
|||||||
durationSeconds: number,
|
durationSeconds: number,
|
||||||
options?: {
|
options?: {
|
||||||
bossesCleared?: number
|
bossesCleared?: number
|
||||||
experienceMode?: 'default' | 'pvp-boss-quarter-level'
|
experienceMode?: 'default' | 'pvp-boss-quarter-level' | 'pvp-fight-twelfth-level'
|
||||||
|
fightsCleared?: number
|
||||||
lootSourceEncounterId?: number
|
lootSourceEncounterId?: number
|
||||||
roguelikeStage?: number
|
roguelikeStage?: number
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,56 @@
|
|||||||
|
import { requestGameApiJson } from './gameRepository'
|
||||||
|
|
||||||
export type PvpContentType = 'dungeon' | 'raid'
|
export type PvpContentType = 'dungeon' | 'raid'
|
||||||
export type CpuDifficulty = 1 | 2 | 3 | 4 | 5
|
export type CpuDifficulty = 1 | 2 | 3 | 4 | 5
|
||||||
|
export type PvpMatchSide = 'a' | 'b'
|
||||||
|
|
||||||
|
export type PvpPlayerInfo = {
|
||||||
|
side: PvpMatchSide
|
||||||
|
accountId: number
|
||||||
|
characterId: number
|
||||||
|
characterName: string
|
||||||
|
className: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PvpUpgradeChoicePayload = {
|
||||||
|
encounterIndex: number
|
||||||
|
buffId: string
|
||||||
|
debuffId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PvpMatchSnapshot<TSideState = unknown> = {
|
||||||
|
id: string
|
||||||
|
contentType: PvpContentType
|
||||||
|
startStage: number
|
||||||
|
createdAt: number
|
||||||
|
players: Record<PvpMatchSide, PvpPlayerInfo>
|
||||||
|
states: Partial<Record<PvpMatchSide, TSideState>>
|
||||||
|
statuses: Partial<Record<PvpMatchSide, 'playing' | 'upgrade-choice' | 'won' | 'lost'>>
|
||||||
|
progress: Partial<Record<PvpMatchSide, {
|
||||||
|
stage: number
|
||||||
|
encounterIndex: number
|
||||||
|
encountersCleared: number
|
||||||
|
enemyHealth: number
|
||||||
|
alive: boolean
|
||||||
|
elapsedTicks: number
|
||||||
|
}>>
|
||||||
|
upgradeChoices: Partial<Record<PvpMatchSide, Record<string, PvpUpgradeChoicePayload>>>
|
||||||
|
rematchRequests?: Partial<Record<PvpMatchSide, boolean>>
|
||||||
|
updatedAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PvpQueueResponse<TSideState = unknown> = {
|
||||||
|
ticketId: string
|
||||||
|
status: 'waiting' | 'matched'
|
||||||
|
match?: PvpMatchSnapshot<TSideState>
|
||||||
|
side?: PvpMatchSide
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PvpRematchResponse<TSideState = unknown> = {
|
||||||
|
status: 'waiting' | 'matched'
|
||||||
|
match?: PvpMatchSnapshot<TSideState>
|
||||||
|
side?: PvpMatchSide
|
||||||
|
}
|
||||||
|
|
||||||
export type CpuPvpLeaderboardEntry = {
|
export type CpuPvpLeaderboardEntry = {
|
||||||
characterName: string
|
characterName: string
|
||||||
@@ -66,3 +117,65 @@ export function recordPvpRoguelikeCheckpoint(
|
|||||||
localStorage.setItem(checkpointStorageKey(characterId, contentType), String(next))
|
localStorage.setItem(checkpointStorageKey(characterId, contentType), String(next))
|
||||||
return next
|
return next
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function joinPvpQueue<TSideState>(
|
||||||
|
contentType: PvpContentType,
|
||||||
|
startStage: number,
|
||||||
|
): Promise<PvpQueueResponse<TSideState>> {
|
||||||
|
return requestGameApiJson('/api/pvp/queue', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ contentType, startStage }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkPvpQueue<TSideState>(ticketId: string): Promise<PvpQueueResponse<TSideState>> {
|
||||||
|
return requestGameApiJson(`/api/pvp/queue/${encodeURIComponent(ticketId)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cancelPvpQueue(ticketId: string): Promise<{ ok: true }> {
|
||||||
|
return requestGameApiJson(`/api/pvp/queue/${encodeURIComponent(ticketId)}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function publishPvpMatchState<TSideState>(
|
||||||
|
matchId: string,
|
||||||
|
payload: {
|
||||||
|
state: TSideState
|
||||||
|
status: 'playing' | 'upgrade-choice' | 'won' | 'lost'
|
||||||
|
stage: number
|
||||||
|
encounterIndex: number
|
||||||
|
encountersCleared: number
|
||||||
|
enemyHealth: number
|
||||||
|
alive: boolean
|
||||||
|
elapsedTicks: number
|
||||||
|
},
|
||||||
|
): Promise<PvpMatchSnapshot<TSideState>> {
|
||||||
|
return requestGameApiJson(`/api/pvp/matches/${encodeURIComponent(matchId)}/state`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadPvpMatch<TSideState>(matchId: string): Promise<PvpMatchSnapshot<TSideState>> {
|
||||||
|
return requestGameApiJson(`/api/pvp/matches/${encodeURIComponent(matchId)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function submitPvpUpgradeChoice(
|
||||||
|
matchId: string,
|
||||||
|
payload: PvpUpgradeChoicePayload,
|
||||||
|
): Promise<PvpMatchSnapshot> {
|
||||||
|
return requestGameApiJson(`/api/pvp/matches/${encodeURIComponent(matchId)}/upgrade-choice`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requestPvpRematch<TSideState>(matchId: string): Promise<PvpRematchResponse<TSideState>> {
|
||||||
|
return requestGameApiJson(`/api/pvp/matches/${encodeURIComponent(matchId)}/rematch`, {
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user