Compare commits

..

8 Commits

Author SHA1 Message Date
Warren H 421540c52b Android build v1.0.53 2026-06-21 20:07:26 -04:00
Warren H 1e24aecad8 Android build v1.0.52 2026-06-21 12:35:10 -04:00
Warren H c9fb28ab6d Android build v1.0.50 2026-06-21 12:34:35 -04:00
Warren H c1e2c6d8b5 Android build v1.0.50 2026-06-21 00:27:07 -04:00
Warren H f7b041f86f Android build v1.0.49 2026-06-21 00:13:06 -04:00
Warren H 05bd70a9fe Android build v1.0.47 2026-06-20 23:49:20 -04:00
Warren H bb5c7e6e21 Android build v1.0.46 2026-06-20 23:45:21 -04:00
Warren H 14bec979e6 Android build v1.0.45 2026-06-20 23:13:55 -04:00
29 changed files with 1656 additions and 129 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+2 -2
View File
@@ -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 64 versionCode 72
versionName "1.0.44" versionName "1.0.53"
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 -53
View File
@@ -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,51 +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 (1201, 983003, 15); 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 (1202, 983003, 15); 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 (1203, 983003, 15); 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 (1204, 1083003, 15); 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 (1205, 1083003, 15); 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 (1206, 1083003, 15); 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 (1207, 1183003, 15); 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 (1208, 1183003, 15); 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 (1209, 1183003, 15); 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 (1301, 1283004, 20); 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 (1302, 1283004, 20); 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 (1303, 1283004, 20); 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 (1304, 1383004, 20); 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 (1305, 1383004, 20); 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 (1306, 1383004, 20); 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 (1307, 1483004, 20); 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 (1308, 1483004, 20); 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 (1309, 1483004, 20); 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 (1401, 1583005, 25); 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 (1402, 1583005, 25); 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 (1403, 1583005, 25); 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 (1404, 1683005, 25); 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 (1405, 1683005, 25); 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 (1406, 1683005, 25); 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 (1407, 1783005, 25); 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 (1408, 1783005, 25); 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 (1409, 1783005, 25); 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
View File
@@ -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)
+59
View File
@@ -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

+30
View File
@@ -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

+62
View File
@@ -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

+36
View File
@@ -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

+41
View File
@@ -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

+7 -2
View File
@@ -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 {
+74
View File
@@ -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,
}
}
+258 -2
View File
@@ -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
@@ -2537,6 +2545,212 @@ 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,
updatedAt: match.updatedAt,
}
}
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 matchId = randomBytes(12).toString('base64url')
const match = {
id: matchId,
contentType,
startStage,
createdAt: now,
players: {
a: { side: 'a', ...opponent.player },
b: { side: 'b', ...player },
},
states: {},
statuses: {},
progress: {},
upgradeChoices: {},
updatedAt: now,
}
pvpMatches.set(matchId, match)
opponent.matchId = matchId
opponent.updatedAt = now
const ticketId = randomBytes(12).toString('base64url')
pvpQueue.set(ticketId, {
id: ticketId,
accountId: session.accountId,
characterId: session.characterId,
contentType,
startStage,
player,
matchId,
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)
}
export function gameApiPlugin() { export function gameApiPlugin() {
return { return {
name: 'ashen-halls-game-api', name: 'ashen-halls-game-api',
@@ -2687,7 +2901,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()
@@ -2696,6 +2910,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') {
@@ -2721,6 +2940,43 @@ 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 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)
+285 -23
View File
@@ -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 {
@@ -3519,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;
@@ -3537,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);
@@ -3640,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;
} }
@@ -4703,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;
} }
@@ -5252,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;
@@ -5302,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 {
@@ -5982,7 +6221,7 @@ h2 {
.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;
} }
@@ -6017,6 +6256,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;
+3 -1
View File
@@ -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}
/> />
)} )}
+368 -34
View File
@@ -23,14 +23,24 @@ import {
} from '../dualScreen' } from '../dualScreen'
import { import {
loadPvpRoguelikeCheckpoint, loadPvpRoguelikeCheckpoint,
cancelPvpQueue,
checkPvpQueue,
joinPvpQueue,
loadPvpMatch,
publishPvpMatchState,
randomCpuDifficulty, randomCpuDifficulty,
recordCpuPvpLeaderboard, recordCpuPvpLeaderboard,
recordPvpRoguelikeCheckpoint, recordPvpRoguelikeCheckpoint,
submitPvpUpgradeChoice,
type CpuDifficulty, type CpuDifficulty,
type PvpMatchSnapshot,
type PvpMatchSide,
type PvpContentType, type PvpContentType,
type PvpUpgradeChoicePayload,
} from '../pvpRoguelike' } from '../pvpRoguelike'
const TICK_MS = 700 const TICK_MS = 700
const UPGRADE_CHOICE_SECONDS = 10
type BossMechanic = type BossMechanic =
| 'party-pulse' | 'party-pulse'
@@ -99,6 +109,14 @@ type PvpRunSummary = {
loot: Array<NonNullable<DungeonReward['bonusItem']>> loot: Array<NonNullable<DungeonReward['bonusItem']>>
} }
type LivePvpMatch = {
id: string
side: PvpMatchSide
opponentSide: PvpMatchSide
opponentName: string
opponentClassName: string
}
const BOSS_MECHANICS: BossMechanic[] = [ const BOSS_MECHANICS: BossMechanic[] = [
'party-pulse', 'party-pulse',
'searing-mark', 'searing-mark',
@@ -449,6 +467,8 @@ export function PvPRoguelikeScreen({
const [speedMultiplier, setSpeedMultiplier] = useState<1 | 2>(1) const [speedMultiplier, setSpeedMultiplier] = useState<1 | 2>(1)
const [elapsedTicks, setElapsedTicks] = useState(0) const [elapsedTicks, setElapsedTicks] = useState(0)
const [cpuDifficulty, setCpuDifficulty] = useState<CpuDifficulty | null>(null) const [cpuDifficulty, setCpuDifficulty] = useState<CpuDifficulty | null>(null)
const [liveMatch, setLiveMatch] = useState<LivePvpMatch | null>(null)
const [liveUpgradePending, setLiveUpgradePending] = useState(false)
const [queueMessage, setQueueMessage] = useState('') const [queueMessage, setQueueMessage] = useState('')
const [log, setLog] = useState<CombatLogEntry[]>([{ id: 1, text: 'Queueing opponent...', tone: 'system' }]) const [log, setLog] = useState<CombatLogEntry[]>([{ id: 1, text: 'Queueing opponent...', tone: 'system' }])
const [reward, setReward] = useState<DungeonReward | null>(null) const [reward, setReward] = useState<DungeonReward | null>(null)
@@ -460,6 +480,7 @@ export function PvPRoguelikeScreen({
const [playerDebuffChoices, setPlayerDebuffChoices] = useState<Array<Choice<OpponentDebuffId>>>([]) const [playerDebuffChoices, setPlayerDebuffChoices] = useState<Array<Choice<OpponentDebuffId>>>([])
const [selectedBuff, setSelectedBuff] = useState<Choice<SelfBuffId> | null>(null) const [selectedBuff, setSelectedBuff] = useState<Choice<SelfBuffId> | null>(null)
const [selectedDebuff, setSelectedDebuff] = useState<Choice<OpponentDebuffId> | null>(null) const [selectedDebuff, setSelectedDebuff] = useState<Choice<OpponentDebuffId> | null>(null)
const [upgradeTimeLeft, setUpgradeTimeLeft] = useState(UPGRADE_CHOICE_SECONDS)
const [encountersCleared, setEncountersCleared] = useState(0) const [encountersCleared, setEncountersCleared] = useState(0)
const [paused, setPaused] = useState(false) const [paused, setPaused] = useState(false)
const [targetGroup, setTargetGroup] = useState<0 | 1 | 2>(0) const [targetGroup, setTargetGroup] = useState<0 | 1 | 2>(0)
@@ -471,6 +492,15 @@ export function PvPRoguelikeScreen({
const cpuDefeatedRef = useRef(false) const cpuDefeatedRef = useRef(false)
const playerClearedEncounterRef = useRef(-1) const playerClearedEncounterRef = useRef(-1)
const queuedMatchRef = useRef(false) const queuedMatchRef = useRef(false)
const upgradeChoiceEndsAtRef = useRef(0)
const autoSubmittedUpgradeRef = useRef(false)
const liveMatchRef = useRef<LivePvpMatch | null>(null)
const loggedOpponentDoneRef = useRef(false)
const pendingLiveUpgradeRef = useRef<{
encounterIndex: number
buff: Choice<SelfBuffId>
debuff: Choice<OpponentDebuffId>
} | null>(null)
const encounterPoolRef = useRef(encounterPool) const encounterPoolRef = useRef(encounterPool)
const playerRef = useRef(playerSide) const playerRef = useRef(playerSide)
const cpuRef = useRef(cpuSide) const cpuRef = useRef(cpuSide)
@@ -484,6 +514,7 @@ export function PvPRoguelikeScreen({
? Math.max(encountersCleared, encounterIndex + 1) ? Math.max(encountersCleared, encounterIndex + 1)
: encountersCleared : encountersCleared
const cpuBehavior = cpuDifficulty ? CPU_BEHAVIOR[cpuDifficulty] : CPU_BEHAVIOR[1] const cpuBehavior = cpuDifficulty ? CPU_BEHAVIOR[cpuDifficulty] : CPU_BEHAVIOR[1]
const opponentLabel = liveMatch ? liveMatch.opponentName : `CPU ${cpuDifficulty ?? 1}`
const activeSpellEffects = useMemo( const activeSpellEffects = useMemo(
() => new Set( () => new Set(
gameClass.talents gameClass.talents
@@ -632,6 +663,9 @@ export function PvPRoguelikeScreen({
setPlayerDebuffChoices([]) setPlayerDebuffChoices([])
setSelectedBuff(null) setSelectedBuff(null)
setSelectedDebuff(null) setSelectedDebuff(null)
setUpgradeTimeLeft(UPGRADE_CHOICE_SECONDS)
upgradeChoiceEndsAtRef.current = 0
autoSubmittedUpgradeRef.current = false
setEncountersCleared(0) setEncountersCleared(0)
setPaused(false) setPaused(false)
setTargetGroup(0) setTargetGroup(0)
@@ -641,34 +675,159 @@ export function PvPRoguelikeScreen({
setShowEndLog(false) setShowEndLog(false)
setFloatingTexts([]) setFloatingTexts([])
setCpuDifficulty(null) setCpuDifficulty(null)
setLiveMatch(null)
liveMatchRef.current = null
setLiveUpgradePending(false)
pendingLiveUpgradeRef.current = null
loggedOpponentDoneRef.current = false
recordedRunRef.current = false recordedRunRef.current = false
rewardClaimedRef.current = false rewardClaimedRef.current = false
cpuDefeatedRef.current = false cpuDefeatedRef.current = false
if (gameMode === 'offline') { const beginCpuMatch = (randomCpu: CpuDifficulty, message: string) => {
const randomCpu = randomCpuDifficulty() liveMatchRef.current = null
setQueueMessage(`Offline mode. CPU ${randomCpu} enters at stage ${matchStartStage}.`) setLiveMatch(null)
setCpuDifficulty(randomCpu) setCpuDifficulty(randomCpu)
setLog([{ id: 1, text: `Offline mode. CPU ${randomCpu} enters at stage ${matchStartStage}.`, tone: 'system' }]) setQueueMessage(message)
const timer = window.setTimeout(() => { setLog([{ id: 1, text: message, tone: 'system' }])
setStatus('playing') setStatus('playing')
addLog(`Stage ${matchStartStage} begins against CPU ${randomCpu}.`, 'system') addLog(`Stage ${matchStartStage} begins against CPU ${randomCpu}.`, 'system')
}
if (gameMode === 'offline') {
const randomCpu = randomCpuDifficulty()
const timer = window.setTimeout(() => {
beginCpuMatch(randomCpu, `Offline mode. CPU ${randomCpu} enters at stage ${matchStartStage}.`)
}, 500) }, 500)
return () => window.clearTimeout(timer) return () => window.clearTimeout(timer)
} }
setQueueMessage(`Searching queue. Stage ${matchStartStage} start ready.`)
setLog([{ id: 1, text: `Searching queue. Stage ${matchStartStage} start ready.`, tone: 'system' }]) let cancelled = false
const timer = window.setTimeout(() => { let ticketId = ''
const randomCpu = randomCpuDifficulty() let pollTimer: number | undefined
setCpuDifficulty(randomCpu) setQueueMessage(`Searching queue for 5s. Stage ${matchStartStage} start ready.`)
setQueueMessage(`No queued player found. CPU ${randomCpu} steps in.`) setLog([{ id: 1, text: `Searching queue for 5s. Stage ${matchStartStage} start ready.`, tone: 'system' }])
const beginLiveMatch = (match: PvpMatchSnapshot<SideState>, side: PvpMatchSide) => {
if (cancelled) return
const opponentSide: PvpMatchSide = side === 'a' ? 'b' : 'a'
const opponent = match.players[opponentSide]
const nextLiveMatch = {
id: match.id,
side,
opponentSide,
opponentName: opponent.characterName,
opponentClassName: opponent.className,
}
liveMatchRef.current = nextLiveMatch
setLiveMatch(nextLiveMatch)
setCpuDifficulty(null)
const opponentBase = starterSide(
cpuPartyTemplate.map((member) => ({
...member,
name: member.id === 'mira' ? opponent.characterName : member.name,
})),
maxResource,
)
opponentBase.enemyHealth = firstEncounter.maxHealth
cpuRef.current = opponentBase
setCpuSide(opponentBase)
setQueueMessage(`${opponent.characterName} found. Match begins.`)
setLog([{ id: 1, text: `${opponent.characterName} found. Stage ${matchStartStage} begins.`, tone: 'system' }])
setStatus('playing') setStatus('playing')
addLog(`No queued player found. CPU ${randomCpu} steps in at stage ${matchStartStage}.`, 'system') }
}, 1400) const fallbackTimer = window.setTimeout(() => {
return () => window.clearTimeout(timer) if (cancelled || liveMatchRef.current) return
cancelled = true
if (ticketId) cancelPvpQueue(ticketId).catch(() => undefined)
const randomCpu = randomCpuDifficulty()
beginCpuMatch(randomCpu, `No queued player found after 5s. CPU ${randomCpu} steps in.`)
}, 5000)
const pollQueue = () => {
if (!ticketId || cancelled) return
checkPvpQueue<SideState>(ticketId)
.then((result) => {
if (cancelled) return
if (result.status === 'matched' && result.match && result.side) {
window.clearTimeout(fallbackTimer)
if (pollTimer) window.clearTimeout(pollTimer)
beginLiveMatch(result.match, result.side)
return
}
pollTimer = window.setTimeout(pollQueue, 500)
})
.catch(() => {
if (!cancelled) pollTimer = window.setTimeout(pollQueue, 700)
})
}
joinPvpQueue<SideState>(contentType, matchStartStage)
.then((result) => {
if (cancelled) return
ticketId = result.ticketId
if (result.status === 'matched' && result.match && result.side) {
window.clearTimeout(fallbackTimer)
beginLiveMatch(result.match, result.side)
return
}
pollTimer = window.setTimeout(pollQueue, 500)
})
.catch(() => {
if (cancelled) return
window.clearTimeout(fallbackTimer)
cancelled = true
const randomCpu = randomCpuDifficulty()
beginCpuMatch(randomCpu, `PvP server unavailable. CPU ${randomCpu} steps in.`)
})
return () => {
cancelled = true
window.clearTimeout(fallbackTimer)
if (pollTimer) window.clearTimeout(pollTimer)
if (ticketId && !liveMatchRef.current) cancelPvpQueue(ticketId).catch(() => undefined)
}
}, [addLog, contentType, cpuPartyTemplate, gameMode, maxResource, partyTemplate, profile.character.id, setSelectedTargetId]) }, [addLog, contentType, cpuPartyTemplate, gameMode, maxResource, partyTemplate, profile.character.id, setSelectedTargetId])
useEffect(() => startMatch(), [startMatch]) useEffect(() => startMatch(), [startMatch])
useEffect(() => {
if (!liveMatch || status === 'queueing') return
let stopped = false
const syncMatch = () => {
publishPvpMatchState<SideState>(liveMatch.id, {
state: playerRef.current,
status: status === 'upgrade-choice' ? 'upgrade-choice' : status,
stage,
encounterIndex,
encountersCleared,
enemyHealth: playerRef.current.enemyHealth,
alive: playerRef.current.party.some((member) => member.health > 0),
elapsedTicks,
})
.then((snapshot) => {
if (stopped) return
const opponentState = snapshot.states[liveMatch.opponentSide]
if (opponentState) {
cpuRef.current = opponentState
setCpuSide(opponentState)
}
const opponentStatus = snapshot.statuses[liveMatch.opponentSide]
if (opponentStatus === 'lost' && !loggedOpponentDoneRef.current) {
loggedOpponentDoneRef.current = true
cpuDefeatedRef.current = true
addLog(`${liveMatch.opponentName} fell. Finish the boss for XP.`, 'loot')
}
if (opponentStatus === 'won' && status !== 'won' && status !== 'lost') {
finishRoguelikeRun()
setStatus('lost')
addLog(`${liveMatch.opponentName} finished first.`, 'danger')
}
})
.catch(() => undefined)
}
syncMatch()
const timer = window.setInterval(syncMatch, 700)
return () => {
stopped = true
window.clearInterval(timer)
}
}, [addLog, encounterIndex, encountersCleared, elapsedTicks, finishRoguelikeRun, liveMatch, stage, status])
const applySpell = useCallback(( const applySpell = useCallback((
current: SideState, current: SideState,
setCurrent: React.Dispatch<React.SetStateAction<SideState>>, setCurrent: React.Dispatch<React.SetStateAction<SideState>>,
@@ -981,6 +1140,9 @@ export function PvPRoguelikeScreen({
}, [activeSpellEffects, addFloatingHeal, elapsedTicks, maxResource]) }, [activeSpellEffects, addFloatingHeal, elapsedTicks, maxResource])
const beginUpgradePhase = useCallback(() => { const beginUpgradePhase = useCallback(() => {
upgradeChoiceEndsAtRef.current = Date.now() + UPGRADE_CHOICE_SECONDS * 1000
autoSubmittedUpgradeRef.current = false
setUpgradeTimeLeft(UPGRADE_CHOICE_SECONDS)
setPlayerBuffChoices(chooseRandom(selfBuffChoicesCatalog, 3)) setPlayerBuffChoices(chooseRandom(selfBuffChoicesCatalog, 3))
setPlayerDebuffChoices(chooseRandom(opponentDebuffChoicesCatalog, 3)) setPlayerDebuffChoices(chooseRandom(opponentDebuffChoicesCatalog, 3))
setSelectedBuff(null) setSelectedBuff(null)
@@ -992,9 +1154,9 @@ export function PvPRoguelikeScreen({
if (status !== 'playing' || paused || !encounter) return if (status !== 'playing' || paused || !encounter) return
const timer = window.setInterval(() => { const timer = window.setInterval(() => {
setElapsedTicks((value) => value + 1) setElapsedTicks((value) => value + 1)
cpuTakeTurn() if (!liveMatch) cpuTakeTurn()
const nextPlayer = advanceSide(playerRef.current, 'player', encounter) const nextPlayer = advanceSide(playerRef.current, 'player', encounter)
const nextCpu = advanceSide(cpuRef.current, 'cpu', encounter) const nextCpu = liveMatch ? cpuRef.current : advanceSide(cpuRef.current, 'cpu', encounter)
if (nextPlayer.enemyHealth <= 0 && playerClearedEncounterRef.current !== encounterIndex) { if (nextPlayer.enemyHealth <= 0 && playerClearedEncounterRef.current !== encounterIndex) {
playerClearedEncounterRef.current = encounterIndex playerClearedEncounterRef.current = encounterIndex
setEncountersCleared((value) => value + 1) setEncountersCleared((value) => value + 1)
@@ -1024,7 +1186,7 @@ export function PvPRoguelikeScreen({
addLog('Your party fell first.', 'danger') addLog('Your party fell first.', 'danger')
return return
} }
if (!nextCpuAlive && !cpuDefeatedRef.current) { if (!liveMatch && !nextCpuAlive && !cpuDefeatedRef.current) {
cpuDefeatedRef.current = true cpuDefeatedRef.current = true
addLog(`CPU ${cpuDifficulty ?? 1} fell. Finish the boss for XP.`, 'loot') addLog(`CPU ${cpuDifficulty ?? 1} fell. Finish the boss for XP.`, 'loot')
} }
@@ -1032,7 +1194,7 @@ export function PvPRoguelikeScreen({
if (encounter.isBoss && cpuDefeatedRef.current) { if (encounter.isBoss && cpuDefeatedRef.current) {
finishRoguelikeRun() finishRoguelikeRun()
setStatus('won') setStatus('won')
addLog('CPU defeated. Match complete.', 'loot') addLog(`${liveMatch ? liveMatch.opponentName : `CPU ${cpuDifficulty ?? 1}`} defeated. Match complete.`, 'loot')
return return
} }
addLog(`${encounter.enemyName} cleared. Choose your next edge.`, 'loot') addLog(`${encounter.enemyName} cleared. Choose your next edge.`, 'loot')
@@ -1040,7 +1202,7 @@ export function PvPRoguelikeScreen({
} }
}, TICK_MS / speedMultiplier) }, TICK_MS / speedMultiplier)
return () => window.clearInterval(timer) return () => window.clearInterval(timer)
}, [addLog, advanceSide, awardBossReward, beginUpgradePhase, checkpointStage, contentType, cpuDifficulty, cpuTakeTurn, encounter, encounterIndex, encountersCleared, finishRoguelikeRun, paused, profile.character.id, speedMultiplier, stage, status]) }, [addLog, advanceSide, awardBossReward, beginUpgradePhase, checkpointStage, contentType, cpuDifficulty, cpuTakeTurn, encounter, encounterIndex, encountersCleared, finishRoguelikeRun, liveMatch, paused, profile.character.id, speedMultiplier, stage, status])
useEffect(() => { useEffect(() => {
if ((status !== 'won' && status !== 'lost') || recordedRunRef.current || !cpuDifficulty) return if ((status !== 'won' && status !== 'lost') || recordedRunRef.current || !cpuDifficulty) return
@@ -1066,8 +1228,117 @@ export function PvPRoguelikeScreen({
window.requestAnimationFrame(() => focusFirstControl()) window.requestAnimationFrame(() => focusFirstControl())
}, [paused]) }, [paused])
const confirmUpgradeChoices = useCallback(() => { const confirmUpgradeChoices = useCallback((
if (!selectedBuff || !selectedDebuff || !cpuDifficulty) return forcedBuff?: Choice<SelfBuffId>,
forcedDebuff?: Choice<OpponentDebuffId>,
) => {
const chosenBuff = forcedBuff ?? selectedBuff
const chosenDebuff = forcedDebuff ?? selectedDebuff
if (!chosenBuff || !chosenDebuff) return
if (liveMatch) {
const submittedBuff = chosenBuff
const submittedDebuff = chosenDebuff
const clearedEncounterIndex = encounterIndex
const applyLiveUpgrade = (opponentChoice: PvpUpgradeChoicePayload) => {
let nextPlayer = {
...playerRef.current,
buffs: [...playerRef.current.buffs, submittedBuff.id],
}
if (opponentChoice.debuffId === 'opp-purge-random-buff') {
nextPlayer = removeRandomBuff(nextPlayer)
} else if (opponentDebuffChoicesCatalog.some((choice) => choice.id === opponentChoice.debuffId)) {
nextPlayer = { ...nextPlayer, debuffs: [...nextPlayer.debuffs, opponentChoice.debuffId as OpponentDebuffId] }
}
const clearedBoss = encounter.isBoss
if (clearedBoss && cpuDefeatedRef.current) {
finishRoguelikeRun()
setStatus('won')
setLiveUpgradePending(false)
addLog(`${liveMatch.opponentName} defeated. Match complete.`, 'loot')
return
}
const nextStage = clearedBoss ? stage + 1 : stage
const nextSegment = clearedBoss ? buildEncounterSegment(encounterPool, nextStage, contentType) : []
const nextEncounter = clearedBoss ? nextSegment[0] : encounters[encounterIndex + 1]
if (!nextEncounter) {
finishRoguelikeRun()
setStatus('won')
setLiveUpgradePending(false)
addLog('No further encounters remain.', 'loot')
return
}
nextPlayer = {
...nextPlayer,
party: nextPlayer.party.map((member) => ({
...member,
health: member.health <= 0 ? 0 : clamp(member.health + Math.round(member.maxHealth * 0.3), 0, member.maxHealth),
debuff: undefined,
debuffTicks: undefined,
poisonStacks: undefined,
maxHealthPenaltyTicks: undefined,
healingReductionTicks: undefined,
})),
resource: clamp(nextPlayer.resource + Math.round(maxResource * 0.25), 0, maxResource),
cooldowns: {},
enemyHealth: nextEncounter.maxHealth,
}
if (clearedBoss) {
setStage(nextStage)
setEncounters((current) => [...current, ...nextSegment])
}
setEncounterIndex((value) => value + 1)
setPlayerSide(nextPlayer)
playerRef.current = nextPlayer
setElapsedTicks(0)
setLiveUpgradePending(false)
pendingLiveUpgradeRef.current = null
setStatus('playing')
const opponentDebuff = opponentDebuffChoicesCatalog.find((choice) => choice.id === opponentChoice.debuffId)
addLog(
`You chose ${submittedBuff.name} and ${submittedDebuff.name}. ${liveMatch.opponentName} chose ${opponentDebuff?.name ?? 'an opponent debuff'}.`,
'system',
)
}
setLiveUpgradePending(true)
pendingLiveUpgradeRef.current = {
encounterIndex: clearedEncounterIndex,
buff: submittedBuff,
debuff: submittedDebuff,
}
addLog(`Waiting for ${liveMatch.opponentName} to choose.`, 'system')
submitPvpUpgradeChoice(liveMatch.id, {
encounterIndex: clearedEncounterIndex,
buffId: submittedBuff.id,
debuffId: submittedDebuff.id,
}).catch((reason: unknown) => {
setLiveUpgradePending(false)
addLog(reason instanceof Error ? reason.message : 'Unable to submit PvP upgrade choice.', 'danger')
})
let attempts = 0
const waitForOpponent = () => {
attempts += 1
loadPvpMatch<SideState>(liveMatch.id)
.then((snapshot) => {
const opponentChoice = snapshot.upgradeChoices[liveMatch.opponentSide]?.[String(clearedEncounterIndex)]
if (opponentChoice) {
applyLiveUpgrade(opponentChoice)
return
}
if (attempts < 120 && pendingLiveUpgradeRef.current) {
window.setTimeout(waitForOpponent, 500)
}
})
.catch(() => {
if (attempts < 120 && pendingLiveUpgradeRef.current) {
window.setTimeout(waitForOpponent, 700)
}
})
}
window.setTimeout(waitForOpponent, 250)
return
}
if (!cpuDifficulty) return
const cpuBuffChoices = chooseRandom(selfBuffChoicesCatalog, 3) const cpuBuffChoices = chooseRandom(selfBuffChoicesCatalog, 3)
const cpuDebuffChoices = chooseRandom(opponentDebuffChoicesCatalog, 3) const cpuDebuffChoices = chooseRandom(opponentDebuffChoicesCatalog, 3)
const cpuBuff = selectCpuChoice(cpuBuffChoices, cpuDifficulty, (choice) => scoreSelfBuff(choice, starterSpells)) const cpuBuff = selectCpuChoice(cpuBuffChoices, cpuDifficulty, (choice) => scoreSelfBuff(choice, starterSpells))
@@ -1075,17 +1346,17 @@ export function PvPRoguelikeScreen({
let nextPlayer = { let nextPlayer = {
...playerRef.current, ...playerRef.current,
buffs: [...playerRef.current.buffs, selectedBuff.id], buffs: [...playerRef.current.buffs, chosenBuff.id],
} }
let nextCpu = { let nextCpu = {
...cpuRef.current, ...cpuRef.current,
buffs: [...cpuRef.current.buffs, cpuBuff.id], buffs: [...cpuRef.current.buffs, cpuBuff.id],
} }
if (selectedDebuff.id === 'opp-purge-random-buff') { if (chosenDebuff.id === 'opp-purge-random-buff') {
nextCpu = removeRandomBuff(nextCpu) nextCpu = removeRandomBuff(nextCpu)
} else { } else {
nextCpu = { ...nextCpu, debuffs: [...nextCpu.debuffs, selectedDebuff.id] } nextCpu = { ...nextCpu, debuffs: [...nextCpu.debuffs, chosenDebuff.id] }
} }
if (cpuDebuff.id === 'opp-purge-random-buff') { if (cpuDebuff.id === 'opp-purge-random-buff') {
@@ -1153,8 +1424,41 @@ export function PvPRoguelikeScreen({
cpuRef.current = nextCpu cpuRef.current = nextCpu
setElapsedTicks(0) setElapsedTicks(0)
setStatus('playing') setStatus('playing')
addLog(`You chose ${selectedBuff.name} and ${selectedDebuff.name}. CPU ${cpuDifficulty} chose ${cpuBuff.name} and ${cpuDebuff.name}.`, 'system') addLog(`You chose ${chosenBuff.name} and ${chosenDebuff.name}. CPU ${cpuDifficulty} chose ${cpuBuff.name} and ${cpuDebuff.name}.`, 'system')
}, [addLog, contentType, cpuDifficulty, encounter, encounterIndex, encounterPool, encounters, finishRoguelikeRun, maxResource, opponentDebuffChoicesCatalog, selectedBuff, selectedDebuff, selfBuffChoicesCatalog, stage, starterSpells]) }, [addLog, contentType, cpuDifficulty, encounter, encounterIndex, encounterPool, encounters, finishRoguelikeRun, liveMatch, maxResource, opponentDebuffChoicesCatalog, selectedBuff, selectedDebuff, selfBuffChoicesCatalog, stage, starterSpells])
useEffect(() => {
if (status !== 'upgrade-choice' || liveUpgradePending) return
if (upgradeChoiceEndsAtRef.current <= 0) {
upgradeChoiceEndsAtRef.current = Date.now() + UPGRADE_CHOICE_SECONDS * 1000
}
const updateTimer = () => {
const remaining = Math.max(0, (upgradeChoiceEndsAtRef.current - Date.now()) / 1000)
setUpgradeTimeLeft(remaining)
if (remaining > 0 || autoSubmittedUpgradeRef.current) return
autoSubmittedUpgradeRef.current = true
const autoBuff = selectedBuff ?? playerBuffChoices[Math.floor(Math.random() * playerBuffChoices.length)]
const autoDebuff = selectedDebuff ?? playerDebuffChoices[Math.floor(Math.random() * playerDebuffChoices.length)]
if (autoBuff) setSelectedBuff(autoBuff)
if (autoDebuff) setSelectedDebuff(autoDebuff)
if (autoBuff && autoDebuff) {
addLog('Upgrade timer expired. Random choices selected.', 'system')
confirmUpgradeChoices(autoBuff, autoDebuff)
}
}
updateTimer()
const timer = window.setInterval(updateTimer, 100)
return () => window.clearInterval(timer)
}, [
addLog,
confirmUpgradeChoices,
liveUpgradePending,
playerBuffChoices,
playerDebuffChoices,
selectedBuff,
selectedDebuff,
status,
])
useGameAction((action) => { useGameAction((action) => {
if (action === 'toggleSpeed') { if (action === 'toggleSpeed') {
@@ -1212,6 +1516,16 @@ export function PvPRoguelikeScreen({
encounterIndex, encounterIndex,
encounterCount: encounters.length, encounterCount: encounters.length,
party: playerSide.party, party: playerSide.party,
opponentName: opponentLabel,
opponentClassName: liveMatch?.opponentClassName ?? (cpuDifficulty ? `CPU ${cpuDifficulty}` : 'CPU'),
opponentParty: cpuSide.party,
opponentResource: cpuSide.resource,
opponentEnemyHealth: cpuSide.enemyHealth,
opponentBuffSummary: cpuSide.buffs.length > 0 ? summarizeStacks(cpuSide.buffs, selfBuffChoicesCatalog) : 'none',
opponentDebuffSummary: cpuSide.debuffs.length > 0 ? summarizeStacks(cpuSide.debuffs, opponentDebuffChoicesCatalog) : 'none',
floatingTexts: floatingTexts
.filter((entry) => entry.side === 'player')
.map(({ id, memberId, value }) => ({ id, memberId, value })),
partySize: playerSide.party.length, partySize: playerSide.party.length,
selectedId, selectedId,
log, log,
@@ -1236,6 +1550,12 @@ export function PvPRoguelikeScreen({
}), [ }), [
bindings, bindings,
controllerIconStyle, controllerIconStyle,
cpuDifficulty,
cpuSide.buffs,
cpuSide.debuffs,
cpuSide.enemyHealth,
cpuSide.party,
cpuSide.resource,
directPartyTargeting, directPartyTargeting,
encounter.description, encounter.description,
encounter.enemyName, encounter.enemyName,
@@ -1243,10 +1563,15 @@ export function PvPRoguelikeScreen({
encounter.maxHealth, encounter.maxHealth,
encounterIndex, encounterIndex,
encounters.length, encounters.length,
floatingTexts,
gameClass.resourceName, gameClass.resourceName,
lastDevice, lastDevice,
liveMatch?.opponentClassName,
log, log,
maxResource, maxResource,
opponentDebuffChoicesCatalog,
opponentLabel,
selfBuffChoicesCatalog,
paused, paused,
playerAlive, playerAlive,
playerSide.buffs, playerSide.buffs,
@@ -1281,6 +1606,7 @@ export function PvPRoguelikeScreen({
{dualScreenEnabled && status !== 'queueing' && ( {dualScreenEnabled && status !== 'queueing' && (
<DualScreenTopCombat <DualScreenTopCombat
state={dualScreenState} state={dualScreenState}
onCastSpell={castPlayerSpell}
onSelectTarget={setSelectedTargetId} onSelectTarget={setSelectedTargetId}
/> />
)} )}
@@ -1317,7 +1643,6 @@ export function PvPRoguelikeScreen({
<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.floor(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
@@ -1357,7 +1682,7 @@ export function PvPRoguelikeScreen({
<small>{Math.max(0, Math.floor(playerSide.enemyHealth))} / {encounter.maxHealth}</small> <small>{Math.max(0, Math.floor(playerSide.enemyHealth))} / {encounter.maxHealth}</small>
</div> </div>
<div> <div>
<strong>CPU clear</strong> <strong>{liveMatch ? `${liveMatch.opponentName} clear` : 'CPU clear'}</strong>
<div className="bar enemy-health boss-bar"> <div className="bar enemy-health boss-bar">
<span style={{ width: `${(cpuSide.enemyHealth / encounter.maxHealth) * 100}%` }} /> <span style={{ width: `${(cpuSide.enemyHealth / encounter.maxHealth) * 100}%` }} />
</div> </div>
@@ -1391,14 +1716,16 @@ export function PvPRoguelikeScreen({
) )
})} })}
</div> </div>
<p className="roguelike-upgrade-list">CPU {cpuDifficulty} | Encounters cleared: {encountersCleared}</p> <p className="roguelike-upgrade-list">
{liveMatch ? `${liveMatch.opponentName} (${liveMatch.opponentClassName})` : `CPU ${cpuDifficulty}`} | Encounters cleared: {encountersCleared}
</p>
</section> </section>
<section className="combat-panel pvp-side"> <section className="combat-panel pvp-side">
<div className="encounter-header"> <div className="encounter-header">
<div> <div>
<p className="eyebrow">Opponent</p> <p className="eyebrow">Opponent</p>
<h2>CPU {cpuDifficulty}</h2> <h2>{opponentLabel}</h2>
</div> </div>
<div className="resource-row pvp-resource-row"> <div className="resource-row pvp-resource-row">
<div className="pvp-resource-wrap"> <div className="pvp-resource-wrap">
@@ -1418,7 +1745,6 @@ export function PvPRoguelikeScreen({
<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.floor(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
@@ -1446,6 +1772,13 @@ export function PvPRoguelikeScreen({
{status === 'upgrade-choice' && ( {status === 'upgrade-choice' && (
<div className="result-screen"> <div className="result-screen">
<div className="pvp-upgrade-dialog"> <div className="pvp-upgrade-dialog">
<div className="pvp-upgrade-header">
<div>
<p className="eyebrow">Choose Edge</p>
<h2>{encounter.isBoss ? `Stage ${stage} Boss Cleared` : `${encounter.enemyName} Cleared`}</h2>
</div>
<strong className={upgradeTimeLeft <= 3 ? 'danger' : ''}>{upgradeTimeLeft.toFixed(1)}s</strong>
</div>
<div className="pvp-choice-columns"> <div className="pvp-choice-columns">
<div> <div>
<strong>Self Buff</strong> <strong>Self Buff</strong>
@@ -1480,8 +1813,9 @@ export function PvPRoguelikeScreen({
</div> </div>
</div> </div>
</div> </div>
<button className="secondary-result-button" disabled={!selectedBuff || !selectedDebuff} onClick={confirmUpgradeChoices} type="button"> {liveUpgradePending && <p>Waiting for opponent choice...</p>}
Continue <button className="secondary-result-button" disabled={!selectedBuff || !selectedDebuff || liveUpgradePending} onClick={() => confirmUpgradeChoices()} type="button">
{liveUpgradePending ? 'Waiting' : 'Continue'}
</button> </button>
</div> </div>
</div> </div>
@@ -1502,7 +1836,7 @@ export function PvPRoguelikeScreen({
<div className="result-screen"> <div className="result-screen">
<div> <div>
<p className="eyebrow">{status === 'won' ? 'Victory' : 'Defeat'}</p> <p className="eyebrow">{status === 'won' ? 'Victory' : 'Defeat'}</p>
<h2>{status === 'won' ? `CPU ${cpuDifficulty} Falls` : `CPU ${cpuDifficulty} Wins`}</h2> <h2>{status === 'won' ? `${opponentLabel} Falls` : `${opponentLabel} Wins`}</h2>
<p>{finalEncountersCleared} encounters cleared.</p> <p>{finalEncountersCleared} encounters cleared.</p>
<div className="reward-summary"> <div className="reward-summary">
<p>{runSummary.bossesKilled} bosses killed.</p> <p>{runSummary.bossesKilled} bosses killed.</p>
+104 -5
View File
@@ -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>
) )
} }
+71 -4
View File
@@ -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,
@@ -82,6 +83,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 +109,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 +290,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)!
@@ -628,7 +671,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)
@@ -653,10 +696,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
@@ -698,6 +762,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 {
@@ -848,7 +913,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
@@ -1544,7 +1609,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,
@@ -1552,7 +1619,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() {
+1
View File
@@ -0,0 +1 @@
export const bundledCatalogHash = '07506c52bab428c439f09a9b82e39e6eff2b243fb972d664da48852059bcd937'
+51 -3
View File
@@ -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
} }
], ],
+100
View File
@@ -1,5 +1,49 @@
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>>>
updatedAt: number
}
export type PvpQueueResponse<TSideState = unknown> = {
ticketId: string
status: 'waiting' | 'matched'
match?: PvpMatchSnapshot<TSideState>
side?: PvpMatchSide
}
export type CpuPvpLeaderboardEntry = { export type CpuPvpLeaderboardEntry = {
characterName: string characterName: string
@@ -66,3 +110,59 @@ 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),
})
}