Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5449276521 | |||
| 787e2bbae9 | |||
| 421540c52b | |||
| 1e24aecad8 | |||
| c9fb28ab6d | |||
| c1e2c6d8b5 | |||
| f7b041f86f | |||
| 05bd70a9fe |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -7,8 +7,8 @@ android {
|
||||
applicationId "com.warren.iwanttoheal"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 66
|
||||
versionName "1.0.46"
|
||||
versionCode 74
|
||||
versionName "1.0.55"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
|
||||
+15
@@ -1908,6 +1908,21 @@ JOIN coin_sources
|
||||
ON coin_sources.encounter_id = crafting_recipes.source_encounter_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;
|
||||
|
||||
INSERT INTO gear_upgrade_paths (from_item_id, to_item_id)
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1440" height="900" viewBox="0 0 1440 900">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" x2="1" y1="0" y2="1"><stop offset="0" stop-color="#111827"/><stop offset="1" stop-color="#2f1f16"/></linearGradient>
|
||||
<style>
|
||||
text{font-family:Inter,Arial,sans-serif;fill:#f8f1df}.muted{fill:#c8bca6;font-size:20px}.tiny{fill:#d8cab1;font-size:16px}.title{font-size:30px;font-weight:800}.label{font-size:19px;font-weight:700}.panel{fill:#1b2433;stroke:#6f5535;stroke-width:2;rx:12}.tile{fill:#253244;stroke:#7d6743;stroke-width:2;rx:10}.selected{fill:#31483b;stroke:#d7aa45;stroke-width:4;rx:10}.bar-bg{fill:#2a3444;rx:8}.hp{fill:#5ed17a;rx:8}.danger{fill:#d96b55;rx:8}.mana{fill:#63a9ff;rx:8}.button{fill:#d7aa45;rx:10}.spell{fill:#273449;stroke:#8d7349;stroke-width:2;rx:10}
|
||||
</style>
|
||||
</defs>
|
||||
<rect width="1440" height="900" fill="url(#bg)"/>
|
||||
<text x="48" y="64" class="title">PC Dungeon PvP Roguelike</text>
|
||||
<text x="48" y="96" class="muted">Dungeon party = 6 members per side. Upgrade timer auto-picks at 0s.</text>
|
||||
|
||||
<rect x="36" y="132" width="402" height="704" class="panel"/>
|
||||
<text x="64" y="176" class="title">You</text><text x="64" y="204" class="muted">Mira - Priest</text>
|
||||
<text x="64" y="252" class="label">Party Health</text>
|
||||
<g transform="translate(64 278)">
|
||||
<rect width="150" height="82" class="selected"/><text x="16" y="31" class="label">Tank</text><text x="106" y="31" class="tiny">82%</text><rect x="16" y="52" width="118" height="12" class="bar-bg"/><rect x="16" y="52" width="97" height="12" class="hp"/>
|
||||
<rect x="170" width="150" height="82" class="tile"/><text x="186" y="31" class="label">Mira</text><text x="276" y="31" class="tiny">74%</text><rect x="186" y="52" width="118" height="12" class="bar-bg"/><rect x="186" y="52" width="87" height="12" class="hp"/>
|
||||
<rect y="104" width="150" height="82" class="tile"/><text x="16" y="135" class="label">DPS</text><text x="106" y="135" class="tiny">55%</text><rect x="16" y="156" width="118" height="12" class="bar-bg"/><rect x="16" y="156" width="65" height="12" class="danger"/>
|
||||
<rect x="170" y="104" width="150" height="82" class="tile"/><text x="186" y="135" class="label">DPS</text><text x="276" y="135" class="tiny">92%</text><rect x="186" y="156" width="118" height="12" class="bar-bg"/><rect x="186" y="156" width="109" height="12" class="hp"/>
|
||||
<rect y="208" width="150" height="82" class="tile"/><text x="16" y="239" class="label">DPS</text><text x="106" y="239" class="tiny">66%</text><rect x="16" y="260" width="118" height="12" class="bar-bg"/><rect x="16" y="260" width="78" height="12" class="hp"/>
|
||||
<rect x="170" y="208" width="150" height="82" class="tile"/><text x="186" y="239" class="label">DPS</text><text x="276" y="239" class="tiny">41%</text><rect x="186" y="260" width="118" height="12" class="bar-bg"/><rect x="186" y="260" width="48" height="12" class="danger"/>
|
||||
</g>
|
||||
<text x="64" y="626" class="tiny">Buffs: Wide Radiance x1</text><text x="64" y="654" class="tiny">Debuffs: Mana Squeeze x1</text>
|
||||
|
||||
<rect x="472" y="132" width="496" height="704" class="panel"/>
|
||||
<text x="504" y="176" class="title">Stage 15 Boss</text><text x="504" y="204" class="muted">Bulldrome Guardian</text>
|
||||
<text x="504" y="260" class="label">Your Clear</text><rect x="504" y="278" width="400" height="24" class="bar-bg"/><rect x="504" y="278" width="176" height="24" class="danger"/>
|
||||
<text x="504" y="342" class="label">Astra Clear</text><rect x="504" y="360" width="400" height="24" class="bar-bg"/><rect x="504" y="360" width="246" height="24" class="danger"/>
|
||||
<g transform="translate(520 436)">
|
||||
<rect width="400" height="260" fill="#161f2c" stroke="#d7aa45" stroke-width="3" rx="14"/>
|
||||
<text x="32" y="44" class="title">Choose Edge</text><text x="274" y="44" class="title">07.4s</text>
|
||||
<text x="32" y="88" class="label">Self Buff</text><text x="210" y="88" class="label">Opponent Debuff</text>
|
||||
<rect x="32" y="112" width="150" height="52" class="selected"/><text x="46" y="145" class="tiny">+1 Target</text>
|
||||
<rect x="32" y="178" width="150" height="52" class="tile"/><text x="46" y="211" class="tiny">-25% Cost</text>
|
||||
<rect x="210" y="112" width="150" height="52" class="tile"/><text x="224" y="145" class="tiny">Cost Up</text>
|
||||
<rect x="210" y="178" width="150" height="52" class="selected"/><text x="224" y="211" class="tiny">Mana Squeeze</text>
|
||||
</g>
|
||||
<g transform="translate(520 728)">
|
||||
<rect width="56" height="52" class="spell"/><text x="22" y="34" font-size="24" font-weight="800">+</text>
|
||||
<rect x="68" width="56" height="52" class="spell"/><text x="88" y="34" font-size="24" font-weight="800">R</text>
|
||||
<rect x="136" width="56" height="52" class="spell"/><text x="156" y="34" font-size="24" font-weight="800">S</text>
|
||||
<rect x="204" width="56" height="52" class="spell"/><text x="224" y="34" font-size="24" font-weight="800">G</text>
|
||||
<rect x="272" width="56" height="52" class="spell"/><text x="292" y="34" font-size="24" font-weight="800">C</text>
|
||||
<rect x="340" width="56" height="52" class="spell"/><text x="360" y="34" font-size="24" font-weight="800">B</text>
|
||||
</g>
|
||||
|
||||
<rect x="1002" y="132" width="402" height="704" class="panel"/>
|
||||
<text x="1030" y="176" class="title">Opponent</text><text x="1030" y="204" class="muted">Astra - Druid</text>
|
||||
<text x="1030" y="252" class="label">Opponent Party</text>
|
||||
<g transform="translate(1030 278)">
|
||||
<rect width="150" height="82" class="tile"/><text x="16" y="31" class="label">Tank</text><text x="106" y="31" class="tiny">59%</text><rect x="16" y="52" width="118" height="12" class="bar-bg"/><rect x="16" y="52" width="70" height="12" class="danger"/>
|
||||
<rect x="170" width="150" height="82" class="tile"/><text x="186" y="31" class="label">Astra</text><text x="276" y="31" class="tiny">88%</text><rect x="186" y="52" width="118" height="12" class="bar-bg"/><rect x="186" y="52" width="104" height="12" class="hp"/>
|
||||
<rect y="104" width="150" height="82" class="tile"/><text x="16" y="135" class="label">DPS</text><text x="106" y="135" class="tiny">73%</text><rect x="16" y="156" width="118" height="12" class="bar-bg"/><rect x="16" y="156" width="86" height="12" class="hp"/>
|
||||
<rect x="170" y="104" width="150" height="82" class="tile"/><text x="186" y="135" class="label">DPS</text><text x="276" y="135" class="tiny">42%</text><rect x="186" y="156" width="118" height="12" class="bar-bg"/><rect x="186" y="156" width="50" height="12" class="danger"/>
|
||||
<rect y="208" width="150" height="82" class="tile"/><text x="16" y="239" class="label">DPS</text><text x="106" y="239" class="tiny">91%</text><rect x="16" y="260" width="118" height="12" class="bar-bg"/><rect x="16" y="260" width="107" height="12" class="hp"/>
|
||||
<rect x="170" y="208" width="150" height="82" class="tile"/><text x="186" y="239" class="label">DPS</text><text x="276" y="239" class="tiny">66%</text><rect x="186" y="260" width="118" height="12" class="bar-bg"/><rect x="186" y="260" width="78" height="12" class="hp"/>
|
||||
</g>
|
||||
<text x="1030" y="626" class="tiny">Buffs: Dense Shields x1</text><text x="1030" y="654" class="tiny">Debuffs: Cost Up x1</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 7.1 KiB |
@@ -0,0 +1,30 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540" viewBox="0 0 960 540">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" x2="1" y1="0" y2="1"><stop offset="0" stop-color="#101827"/><stop offset="1" stop-color="#271a12"/></linearGradient>
|
||||
<style>
|
||||
text{font-family:Inter,Arial,sans-serif;fill:#f8f1df}.muted{fill:#c8bca6;font-size:15px}.title{font-size:24px;font-weight:800}.label{font-size:16px;font-weight:700}.panel{fill:#1b2433;stroke:#6f5535;stroke-width:2;rx:10}.tile{fill:#253244;stroke:#7d6743;stroke-width:2;rx:8}.selected{fill:#31483b;stroke:#d7aa45;stroke-width:4;rx:8}.bar-bg{fill:#2a3444;rx:7}.hp{fill:#5ed17a;rx:7}.danger{fill:#d96b55;rx:7}.mana{fill:#63a9ff;rx:7}.spell{fill:#273449;stroke:#8d7349;stroke-width:2;rx:10}.ready{fill:#31483b;stroke:#d7aa45;stroke-width:3;rx:10}
|
||||
</style>
|
||||
</defs>
|
||||
<rect width="960" height="540" fill="url(#bg)"/>
|
||||
<text x="24" y="38" class="title">Thor Main - Dungeon PvP</text>
|
||||
<text x="24" y="62" class="muted">Dungeon party shows exactly 6 members. Opponent health stays on bottom screen.</text>
|
||||
<rect x="24" y="86" width="912" height="350" class="panel"/>
|
||||
<text x="52" y="124" class="title">Mira Party</text><text x="760" y="124" class="label">Stage 15 Boss</text>
|
||||
<rect x="760" y="140" width="132" height="16" class="bar-bg"/><rect x="760" y="140" width="58" height="16" class="danger"/>
|
||||
<g transform="translate(52 164)">
|
||||
<rect width="132" height="96" class="selected"/><text x="16" y="28" class="label">Tank</text><text x="16" y="52" class="muted">82 / 100</text><rect x="16" y="68" width="100" height="12" class="bar-bg"/><rect x="16" y="68" width="82" height="12" class="hp"/>
|
||||
<rect x="150" width="132" height="96" class="tile"/><text x="166" y="28" class="label">Mira</text><text x="166" y="52" class="muted">74 / 100</text><rect x="166" y="68" width="100" height="12" class="bar-bg"/><rect x="166" y="68" width="74" height="12" class="hp"/>
|
||||
<rect x="300" width="132" height="96" class="tile"/><text x="316" y="28" class="label">DPS</text><text x="316" y="52" class="muted">55 / 100</text><rect x="316" y="68" width="100" height="12" class="bar-bg"/><rect x="316" y="68" width="55" height="12" class="danger"/>
|
||||
<rect y="116" width="132" height="96" class="tile"/><text x="16" y="144" class="label">DPS</text><text x="16" y="168" class="muted">92 / 100</text><rect x="16" y="184" width="100" height="12" class="bar-bg"/><rect x="16" y="184" width="92" height="12" class="hp"/>
|
||||
<rect x="150" y="116" width="132" height="96" class="tile"/><text x="166" y="144" class="label">DPS</text><text x="166" y="168" class="muted">66 / 100</text><rect x="166" y="184" width="100" height="12" class="bar-bg"/><rect x="166" y="184" width="66" height="12" class="hp"/>
|
||||
<rect x="300" y="116" width="132" height="96" class="tile"/><text x="316" y="144" class="label">DPS</text><text x="316" y="168" class="muted">41 / 100</text><rect x="316" y="184" width="100" height="12" class="bar-bg"/><rect x="316" y="184" width="41" height="12" class="danger"/>
|
||||
</g>
|
||||
<rect x="24" y="452" width="912" height="68" class="panel"/>
|
||||
<rect x="48" y="468" width="52" height="40" class="ready"/><text x="66" y="496" font-size="22" font-weight="800">+</text>
|
||||
<rect x="112" y="468" width="52" height="40" class="spell"/><text x="129" y="496" font-size="22" font-weight="800">R</text><text x="143" y="483" font-size="12">3</text>
|
||||
<rect x="176" y="468" width="52" height="40" class="spell"/><text x="194" y="496" font-size="22" font-weight="800">S</text><text x="207" y="483" font-size="12">8</text>
|
||||
<rect x="240" y="468" width="52" height="40" class="ready"/><text x="258" y="496" font-size="22" font-weight="800">G</text>
|
||||
<rect x="304" y="468" width="52" height="40" class="spell"/><text x="322" y="496" font-size="22" font-weight="800">C</text><text x="335" y="483" font-size="12">5</text>
|
||||
<rect x="368" y="468" width="52" height="40" class="ready"/><text x="386" y="496" font-size="22" font-weight="800">B</text>
|
||||
<text x="650" y="480" class="label">Mana 73 / 100</text><rect x="650" y="492" width="240" height="14" class="bar-bg"/><rect x="650" y="492" width="175" height="14" class="mana"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.1 KiB |
@@ -0,0 +1,62 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1440" height="900" viewBox="0 0 1440 900">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" x2="1" y1="0" y2="1"><stop offset="0" stop-color="#111827"/><stop offset="1" stop-color="#2f1f16"/></linearGradient>
|
||||
<style>
|
||||
text{font-family:Inter,Arial,sans-serif;fill:#f8f1df}.muted{fill:#c8bca6;font-size:20px}.tiny{fill:#d8cab1;font-size:14px}.title{font-size:30px;font-weight:800}.label{font-size:18px;font-weight:700}.panel{fill:#1b2433;stroke:#6f5535;stroke-width:2;rx:12}.tile{fill:#253244;stroke:#7d6743;stroke-width:2;rx:8}.bar-bg{fill:#2a3444;rx:7}.hp{fill:#5ed17a;rx:7}.danger{fill:#d96b55;rx:7}.spell{fill:#273449;stroke:#8d7349;stroke-width:2;rx:10}.selected{fill:#31483b;stroke:#d7aa45;stroke-width:3;rx:8}
|
||||
</style>
|
||||
</defs>
|
||||
<rect width="1440" height="900" fill="url(#bg)"/>
|
||||
<text x="48" y="64" class="title">PC Raid PvP Roguelike</text>
|
||||
<text x="48" y="96" class="muted">Raid party = compact roster. Opponent panel mirrors raid health without crowding center.</text>
|
||||
<rect x="36" y="132" width="430" height="704" class="panel"/>
|
||||
<text x="64" y="176" class="title">You</text><text x="64" y="204" class="muted">Mira Raid Group</text>
|
||||
<g transform="translate(64 238)">
|
||||
<rect width="104" height="56" class="selected"/><text x="10" y="24" class="tiny">Tank 82%</text><rect x="10" y="36" width="84" height="8" class="bar-bg"/><rect x="10" y="36" width="69" height="8" class="hp"/>
|
||||
<rect x="116" width="104" height="56" class="tile"/><text x="126" y="24" class="tiny">Mira 74%</text><rect x="126" y="36" width="84" height="8" class="bar-bg"/><rect x="126" y="36" width="62" height="8" class="hp"/>
|
||||
<rect x="232" width="104" height="56" class="tile"/><text x="242" y="24" class="tiny">DPS 55%</text><rect x="242" y="36" width="84" height="8" class="bar-bg"/><rect x="242" y="36" width="46" height="8" class="danger"/>
|
||||
<rect y="72" width="104" height="56" class="tile"/><text x="10" y="96" class="tiny">DPS 92%</text><rect x="10" y="108" width="84" height="8" class="bar-bg"/><rect x="10" y="108" width="77" height="8" class="hp"/>
|
||||
<rect x="116" y="72" width="104" height="56" class="tile"/><text x="126" y="96" class="tiny">DPS 66%</text><rect x="126" y="108" width="84" height="8" class="bar-bg"/><rect x="126" y="108" width="55" height="8" class="hp"/>
|
||||
<rect x="232" y="72" width="104" height="56" class="tile"/><text x="242" y="96" class="tiny">DPS 41%</text><rect x="242" y="108" width="84" height="8" class="bar-bg"/><rect x="242" y="108" width="34" height="8" class="danger"/>
|
||||
<rect y="144" width="104" height="56" class="tile"/><text x="10" y="168" class="tiny">DPS 88%</text><rect x="10" y="180" width="84" height="8" class="bar-bg"/><rect x="10" y="180" width="74" height="8" class="hp"/>
|
||||
<rect x="116" y="144" width="104" height="56" class="tile"/><text x="126" y="168" class="tiny">DPS 63%</text><rect x="126" y="180" width="84" height="8" class="bar-bg"/><rect x="126" y="180" width="53" height="8" class="hp"/>
|
||||
<rect x="232" y="144" width="104" height="56" class="tile"/><text x="242" y="168" class="tiny">DPS 77%</text><rect x="242" y="180" width="84" height="8" class="bar-bg"/><rect x="242" y="180" width="65" height="8" class="hp"/>
|
||||
<rect y="216" width="104" height="56" class="tile"/><text x="10" y="240" class="tiny">DPS 49%</text><rect x="10" y="252" width="84" height="8" class="bar-bg"/><rect x="10" y="252" width="41" height="8" class="danger"/>
|
||||
<rect x="116" y="216" width="104" height="56" class="tile"/><text x="126" y="240" class="tiny">DPS 96%</text><rect x="126" y="252" width="84" height="8" class="bar-bg"/><rect x="126" y="252" width="81" height="8" class="hp"/>
|
||||
<rect x="232" y="216" width="104" height="56" class="tile"/><text x="242" y="240" class="tiny">DPS 70%</text><rect x="242" y="252" width="84" height="8" class="bar-bg"/><rect x="242" y="252" width="59" height="8" class="hp"/>
|
||||
</g>
|
||||
<text x="64" y="590" class="tiny">Direct target group: 1 / 2</text><text x="64" y="620" class="tiny">Buffs: Wide Radiance x1</text>
|
||||
|
||||
<rect x="502" y="132" width="436" height="704" class="panel"/>
|
||||
<text x="534" y="176" class="title">Raid Stage 20 Boss</text><text x="534" y="204" class="muted">Ashen Warden</text>
|
||||
<text x="534" y="260" class="label">Your Clear</text><rect x="534" y="278" width="340" height="24" class="bar-bg"/><rect x="534" y="278" width="176" height="24" class="danger"/>
|
||||
<text x="534" y="342" class="label">Astra Clear</text><rect x="534" y="360" width="340" height="24" class="bar-bg"/><rect x="534" y="360" width="218" height="24" class="danger"/>
|
||||
<rect x="534" y="438" width="340" height="220" fill="#161f2c" stroke="#d7aa45" stroke-width="3" rx="14"/>
|
||||
<text x="558" y="482" class="title">Choose Edge</text><text x="766" y="482" class="title">10.0s</text>
|
||||
<rect x="558" y="520" width="132" height="48" class="selected"/><text x="572" y="551" class="tiny">Raid Heal +</text>
|
||||
<rect x="708" y="520" width="132" height="48" class="tile"/><text x="722" y="551" class="tiny">Mana Squeeze</text>
|
||||
<rect x="558" y="584" width="132" height="48" class="tile"/><text x="572" y="615" class="tiny">Shield Boost</text>
|
||||
<rect x="708" y="584" width="132" height="48" class="selected"/><text x="722" y="615" class="tiny">Cooldown Up</text>
|
||||
<g transform="translate(534 724)">
|
||||
<rect width="48" height="48" class="spell"/><text x="18" y="32" font-size="22" font-weight="800">+</text>
|
||||
<rect x="60" width="48" height="48" class="spell"/><text x="77" y="32" font-size="22" font-weight="800">R</text>
|
||||
<rect x="120" width="48" height="48" class="spell"/><text x="137" y="32" font-size="22" font-weight="800">S</text>
|
||||
<rect x="180" width="48" height="48" class="spell"/><text x="197" y="32" font-size="22" font-weight="800">G</text>
|
||||
<rect x="240" width="48" height="48" class="spell"/><text x="257" y="32" font-size="22" font-weight="800">C</text>
|
||||
<rect x="300" width="48" height="48" class="spell"/><text x="317" y="32" font-size="22" font-weight="800">B</text>
|
||||
</g>
|
||||
|
||||
<rect x="974" y="132" width="430" height="704" class="panel"/>
|
||||
<text x="1002" y="176" class="title">Opponent Raid</text><text x="1002" y="204" class="muted">Astra Group</text>
|
||||
<g transform="translate(1002 238)">
|
||||
<rect width="104" height="56" class="tile"/><text x="10" y="24" class="tiny">Tank 61%</text><rect x="10" y="36" width="84" height="8" class="bar-bg"/><rect x="10" y="36" width="51" height="8" class="danger"/>
|
||||
<rect x="116" width="104" height="56" class="tile"/><text x="126" y="24" class="tiny">Astra 89%</text><rect x="126" y="36" width="84" height="8" class="bar-bg"/><rect x="126" y="36" width="75" height="8" class="hp"/>
|
||||
<rect x="232" width="104" height="56" class="tile"/><text x="242" y="24" class="tiny">DPS 73%</text><rect x="242" y="36" width="84" height="8" class="bar-bg"/><rect x="242" y="36" width="61" height="8" class="hp"/>
|
||||
<rect y="72" width="104" height="56" class="tile"/><text x="10" y="96" class="tiny">DPS 42%</text><rect x="10" y="108" width="84" height="8" class="bar-bg"/><rect x="10" y="108" width="35" height="8" class="danger"/>
|
||||
<rect x="116" y="72" width="104" height="56" class="tile"/><text x="126" y="96" class="tiny">DPS 91%</text><rect x="126" y="108" width="84" height="8" class="bar-bg"/><rect x="126" y="108" width="76" height="8" class="hp"/>
|
||||
<rect x="232" y="72" width="104" height="56" class="tile"/><text x="242" y="96" class="tiny">DPS 66%</text><rect x="242" y="108" width="84" height="8" class="bar-bg"/><rect x="242" y="108" width="55" height="8" class="hp"/>
|
||||
<rect y="144" width="104" height="56" class="tile"/><text x="10" y="168" class="tiny">DPS 79%</text><rect x="10" y="180" width="84" height="8" class="bar-bg"/><rect x="10" y="180" width="66" height="8" class="hp"/>
|
||||
<rect x="116" y="144" width="104" height="56" class="tile"/><text x="126" y="168" class="tiny">DPS 58%</text><rect x="126" y="180" width="84" height="8" class="bar-bg"/><rect x="126" y="180" width="49" height="8" class="danger"/>
|
||||
<rect x="232" y="144" width="104" height="56" class="tile"/><text x="242" y="168" class="tiny">DPS 84%</text><rect x="242" y="180" width="84" height="8" class="bar-bg"/><rect x="242" y="180" width="71" height="8" class="hp"/>
|
||||
</g>
|
||||
<text x="1002" y="590" class="tiny">Opponent buffs/debuffs visible here</text><text x="1002" y="620" class="tiny">Raid bottom screen can scroll second group if needed</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 8.3 KiB |
@@ -0,0 +1,36 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540" viewBox="0 0 960 540">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" x2="1" y1="0" y2="1"><stop offset="0" stop-color="#101827"/><stop offset="1" stop-color="#271a12"/></linearGradient>
|
||||
<style>
|
||||
text{font-family:Inter,Arial,sans-serif;fill:#f8f1df}.muted{fill:#c8bca6;font-size:15px}.title{font-size:24px;font-weight:800}.label{font-size:15px;font-weight:700}.panel{fill:#1b2433;stroke:#6f5535;stroke-width:2;rx:10}.tile{fill:#253244;stroke:#7d6743;stroke-width:2;rx:8}.selected{fill:#31483b;stroke:#d7aa45;stroke-width:4;rx:8}.bar-bg{fill:#2a3444;rx:7}.hp{fill:#5ed17a;rx:7}.danger{fill:#d96b55;rx:7}.mana{fill:#63a9ff;rx:7}.spell{fill:#273449;stroke:#8d7349;stroke-width:2;rx:10}.ready{fill:#31483b;stroke:#d7aa45;stroke-width:3;rx:10}
|
||||
</style>
|
||||
</defs>
|
||||
<rect width="960" height="540" fill="url(#bg)"/>
|
||||
<text x="24" y="38" class="title">Thor Main - Raid PvP</text>
|
||||
<text x="24" y="62" class="muted">Raid party uses compact tiles plus target group toggle. Bottom screen mirrors opponent raid.</text>
|
||||
<rect x="24" y="86" width="912" height="350" class="panel"/>
|
||||
<text x="52" y="122" class="title">Mira Raid</text><text x="214" y="122" class="muted">Target Group 1 / 2</text>
|
||||
<text x="760" y="122" class="label">Raid Boss</text><rect x="760" y="138" width="132" height="16" class="bar-bg"/><rect x="760" y="138" width="72" height="16" class="danger"/>
|
||||
<g transform="translate(52 156)">
|
||||
<rect width="104" height="72" class="selected"/><text x="12" y="28" class="label">Tank</text><text x="62" y="28" class="muted">82%</text><rect x="12" y="46" width="80" height="10" class="bar-bg"/><rect x="12" y="46" width="66" height="10" class="hp"/>
|
||||
<rect x="118" width="104" height="72" class="tile"/><text x="130" y="28" class="label">Mira</text><text x="180" y="28" class="muted">74%</text><rect x="130" y="46" width="80" height="10" class="bar-bg"/><rect x="130" y="46" width="59" height="10" class="hp"/>
|
||||
<rect x="236" width="104" height="72" class="tile"/><text x="248" y="28" class="label">DPS</text><text x="298" y="28" class="muted">55%</text><rect x="248" y="46" width="80" height="10" class="bar-bg"/><rect x="248" y="46" width="44" height="10" class="danger"/>
|
||||
<rect x="354" width="104" height="72" class="tile"/><text x="366" y="28" class="label">DPS</text><text x="416" y="28" class="muted">92%</text><rect x="366" y="46" width="80" height="10" class="bar-bg"/><rect x="366" y="46" width="74" height="10" class="hp"/>
|
||||
<rect x="472" width="104" height="72" class="tile"/><text x="484" y="28" class="label">DPS</text><text x="534" y="28" class="muted">66%</text><rect x="484" y="46" width="80" height="10" class="bar-bg"/><rect x="484" y="46" width="53" height="10" class="hp"/>
|
||||
<rect x="590" width="104" height="72" class="tile"/><text x="602" y="28" class="label">DPS</text><text x="652" y="28" class="muted">41%</text><rect x="602" y="46" width="80" height="10" class="bar-bg"/><rect x="602" y="46" width="33" height="10" class="danger"/>
|
||||
<rect y="92" width="104" height="72" class="tile"/><text x="12" y="120" class="label">DPS</text><text x="62" y="120" class="muted">88%</text><rect x="12" y="138" width="80" height="10" class="bar-bg"/><rect x="12" y="138" width="70" height="10" class="hp"/>
|
||||
<rect x="118" y="92" width="104" height="72" class="tile"/><text x="130" y="120" class="label">DPS</text><text x="180" y="120" class="muted">63%</text><rect x="130" y="138" width="80" height="10" class="bar-bg"/><rect x="130" y="138" width="50" height="10" class="hp"/>
|
||||
<rect x="236" y="92" width="104" height="72" class="tile"/><text x="248" y="120" class="label">DPS</text><text x="298" y="120" class="muted">77%</text><rect x="248" y="138" width="80" height="10" class="bar-bg"/><rect x="248" y="138" width="62" height="10" class="hp"/>
|
||||
<rect x="354" y="92" width="104" height="72" class="tile"/><text x="366" y="120" class="label">DPS</text><text x="416" y="120" class="muted">49%</text><rect x="366" y="138" width="80" height="10" class="bar-bg"/><rect x="366" y="138" width="39" height="10" class="danger"/>
|
||||
<rect x="472" y="92" width="104" height="72" class="tile"/><text x="484" y="120" class="label">DPS</text><text x="534" y="120" class="muted">96%</text><rect x="484" y="138" width="80" height="10" class="bar-bg"/><rect x="484" y="138" width="77" height="10" class="hp"/>
|
||||
<rect x="590" y="92" width="104" height="72" class="tile"/><text x="602" y="120" class="label">DPS</text><text x="652" y="120" class="muted">70%</text><rect x="602" y="138" width="80" height="10" class="bar-bg"/><rect x="602" y="138" width="56" height="10" class="hp"/>
|
||||
</g>
|
||||
<rect x="24" y="452" width="912" height="68" class="panel"/>
|
||||
<rect x="48" y="468" width="52" height="40" class="ready"/><text x="66" y="496" font-size="22" font-weight="800">+</text>
|
||||
<rect x="112" y="468" width="52" height="40" class="spell"/><text x="129" y="496" font-size="22" font-weight="800">R</text><text x="143" y="483" font-size="12">3</text>
|
||||
<rect x="176" y="468" width="52" height="40" class="spell"/><text x="194" y="496" font-size="22" font-weight="800">S</text><text x="207" y="483" font-size="12">8</text>
|
||||
<rect x="240" y="468" width="52" height="40" class="ready"/><text x="258" y="496" font-size="22" font-weight="800">G</text>
|
||||
<rect x="304" y="468" width="52" height="40" class="spell"/><text x="322" y="496" font-size="22" font-weight="800">C</text><text x="335" y="483" font-size="12">5</text>
|
||||
<rect x="368" y="468" width="52" height="40" class="ready"/><text x="386" y="496" font-size="22" font-weight="800">B</text>
|
||||
<text x="650" y="480" class="label">Mana 73 / 100</text><rect x="650" y="492" width="240" height="14" class="bar-bg"/><rect x="650" y="492" width="175" height="14" class="mana"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.7 KiB |
@@ -0,0 +1,41 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="620" height="540" viewBox="0 0 620 540">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" x2="1" y1="0" y2="1">
|
||||
<stop offset="0" stop-color="#101827"/>
|
||||
<stop offset="1" stop-color="#271a12"/>
|
||||
</linearGradient>
|
||||
<style>
|
||||
text { font-family: Inter, Arial, sans-serif; fill: #f8f1df; }
|
||||
.muted { fill: #c8bca6; font-size: 14px; }
|
||||
.title { font-size: 22px; font-weight: 800; }
|
||||
.label { font-size: 15px; font-weight: 700; }
|
||||
.panel { fill: #1b2433; stroke: #6f5535; stroke-width: 2; rx: 10; }
|
||||
.tile { fill: #253244; stroke: #7d6743; stroke-width: 2; rx: 8; }
|
||||
.bar-bg { fill: #2a3444; rx: 7; }
|
||||
.hp { fill: #5ed17a; rx: 7; }
|
||||
.danger { fill: #d96b55; rx: 7; }
|
||||
.mana { fill: #63a9ff; rx: 7; }
|
||||
</style>
|
||||
</defs>
|
||||
<rect width="620" height="540" fill="url(#bg)"/>
|
||||
<text x="20" y="36" class="title">Thor Bottom Screen - Opponent View</text>
|
||||
<text x="20" y="60" class="muted">Secondary display shows real opponent health/progress.</text>
|
||||
<rect x="20" y="82" width="580" height="424" class="panel"/>
|
||||
<text x="44" y="122" class="title">Astra</text>
|
||||
<text x="44" y="146" class="muted">Druid - live opponent</text>
|
||||
<text x="376" y="122" class="label">Opponent Clear</text>
|
||||
<rect x="376" y="136" width="172" height="16" class="bar-bg"/><rect x="376" y="136" width="106" height="16" class="danger"/>
|
||||
<text x="44" y="192" class="label">Opponent Party Health</text>
|
||||
<g transform="translate(44 216)">
|
||||
<rect width="158" height="72" class="tile"/><text x="14" y="26" class="label">Tank</text><text x="104" y="26" class="muted">59%</text><rect x="14" y="44" width="128" height="12" class="bar-bg"/><rect x="14" y="44" width="76" height="12" class="danger"/>
|
||||
<rect x="176" width="158" height="72" class="tile"/><text x="190" y="26" class="label">Astra</text><text x="280" y="26" class="muted">88%</text><rect x="190" y="44" width="128" height="12" class="bar-bg"/><rect x="190" y="44" width="113" height="12" class="hp"/>
|
||||
<rect x="352" width="158" height="72" class="tile"/><text x="366" y="26" class="label">DPS</text><text x="456" y="26" class="muted">73%</text><rect x="366" y="44" width="128" height="12" class="bar-bg"/><rect x="366" y="44" width="93" height="12" class="hp"/>
|
||||
<rect y="92" width="158" height="72" class="tile"/><text x="14" y="118" class="label">DPS</text><text x="104" y="118" class="muted">42%</text><rect x="14" y="136" width="128" height="12" class="bar-bg"/><rect x="14" y="136" width="54" height="12" class="danger"/>
|
||||
<rect x="176" y="92" width="158" height="72" class="tile"/><text x="190" y="118" class="label">DPS</text><text x="280" y="118" class="muted">91%</text><rect x="190" y="136" width="128" height="12" class="bar-bg"/><rect x="190" y="136" width="116" height="12" class="hp"/>
|
||||
<rect x="352" y="92" width="158" height="72" class="tile"/><text x="366" y="118" class="label">DPS</text><text x="456" y="118" class="muted">66%</text><rect x="366" y="136" width="128" height="12" class="bar-bg"/><rect x="366" y="136" width="84" height="12" class="hp"/>
|
||||
</g>
|
||||
<text x="44" y="420" class="label">Mana 86 / 100</text>
|
||||
<rect x="154" y="408" width="214" height="16" class="bar-bg"/><rect x="154" y="408" width="184" height="16" class="mana"/>
|
||||
<text x="44" y="462" class="muted">Buffs: Dense Shields x1</text>
|
||||
<text x="318" y="462" class="muted">Debuffs: Cost Up x1</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.4 KiB |
@@ -1,5 +1,6 @@
|
||||
import { readFileSync, writeFileSync } from 'node:fs'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import { catalogPayload } from '../server/catalog.mjs'
|
||||
import { getProfile } from '../server/game-api.mjs'
|
||||
|
||||
const database = new DatabaseSync(':memory:')
|
||||
@@ -7,10 +8,14 @@ const database = new DatabaseSync(':memory:')
|
||||
try {
|
||||
database.exec(readFileSync('db/schema.sql', 'utf8'))
|
||||
database.exec(readFileSync('db/seed.sql', 'utf8'))
|
||||
const profile = getProfile(database, 1)
|
||||
const catalog = catalogPayload(getProfile(database, 1))
|
||||
writeFileSync(
|
||||
'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.')
|
||||
} finally {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
+298
-2
@@ -9,6 +9,7 @@ import {
|
||||
import { isIP } from 'node:net'
|
||||
import { extname, resolve, sep } from 'node:path'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import { catalogPayload } from './catalog.mjs'
|
||||
|
||||
const databasePath = fileURLToPath(new URL('../data/game.db', 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 sessionLifetimeSeconds = 60 * 60 * 24 * 30
|
||||
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 = {}) {
|
||||
response.statusCode = status
|
||||
@@ -348,10 +353,13 @@ function currentSession(database, request) {
|
||||
accounts.id AS accountId,
|
||||
accounts.username,
|
||||
characters.id AS characterId,
|
||||
characters.class_id AS classId
|
||||
characters.class_id AS classId,
|
||||
characters.name AS characterName,
|
||||
classes.name AS className
|
||||
FROM sessions
|
||||
JOIN accounts ON accounts.id = sessions.account_id
|
||||
JOIN characters ON characters.id = sessions.active_character_id
|
||||
JOIN classes ON classes.id = characters.class_id
|
||||
WHERE sessions.token_hash = ?
|
||||
AND sessions.expires_at > CURRENT_TIMESTAMP
|
||||
`).get(tokenHash(token)) ?? null
|
||||
@@ -2537,6 +2545,246 @@ function saveProfile(database, characterId, accountId, payload) {
|
||||
}
|
||||
}
|
||||
|
||||
function cleanupPvpMemory(now = Date.now()) {
|
||||
for (const [ticketId, ticket] of pvpQueue.entries()) {
|
||||
if (now - ticket.updatedAt > pvpQueueTtlMs) pvpQueue.delete(ticketId)
|
||||
}
|
||||
for (const [matchId, match] of pvpMatches.entries()) {
|
||||
if (now - match.updatedAt > pvpMatchTtlMs) pvpMatches.delete(matchId)
|
||||
}
|
||||
}
|
||||
|
||||
function validatePvpContentType(value) {
|
||||
if (value !== 'dungeon' && value !== 'raid') {
|
||||
throw new Error('The PvP content type is invalid.')
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function validatePvpStartStage(value) {
|
||||
const startStage = Number(value)
|
||||
if (!Number.isInteger(startStage) || startStage < 1 || startStage > 1000) {
|
||||
throw new Error('The PvP start stage is invalid.')
|
||||
}
|
||||
return startStage
|
||||
}
|
||||
|
||||
function pvpPlayerInfo(session) {
|
||||
return {
|
||||
accountId: session.accountId,
|
||||
characterId: session.characterId,
|
||||
characterName: session.characterName,
|
||||
className: session.className,
|
||||
}
|
||||
}
|
||||
|
||||
function pvpSnapshot(match) {
|
||||
return {
|
||||
id: match.id,
|
||||
contentType: match.contentType,
|
||||
startStage: match.startStage,
|
||||
createdAt: match.createdAt,
|
||||
players: match.players,
|
||||
states: match.states,
|
||||
statuses: match.statuses,
|
||||
progress: match.progress,
|
||||
upgradeChoices: match.upgradeChoices,
|
||||
rematchRequests: match.rematchRequests,
|
||||
updatedAt: match.updatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
function createPvpMatch(contentType, startStage, players, now = Date.now()) {
|
||||
const matchId = randomBytes(12).toString('base64url')
|
||||
const match = {
|
||||
id: matchId,
|
||||
contentType,
|
||||
startStage,
|
||||
createdAt: now,
|
||||
players,
|
||||
states: {},
|
||||
statuses: {},
|
||||
progress: {},
|
||||
upgradeChoices: {},
|
||||
rematchRequests: {},
|
||||
updatedAt: now,
|
||||
}
|
||||
pvpMatches.set(matchId, match)
|
||||
return match
|
||||
}
|
||||
|
||||
function joinPvpQueue(session, payload) {
|
||||
const now = Date.now()
|
||||
cleanupPvpMemory(now)
|
||||
const contentType = validatePvpContentType(payload.contentType)
|
||||
const startStage = validatePvpStartStage(payload.startStage)
|
||||
const existingTicket = [...pvpQueue.values()].find((ticket) =>
|
||||
ticket.accountId === session.accountId
|
||||
&& ticket.characterId === session.characterId
|
||||
&& ticket.contentType === contentType
|
||||
&& ticket.startStage === startStage
|
||||
)
|
||||
if (existingTicket?.matchId) {
|
||||
const match = pvpMatches.get(existingTicket.matchId)
|
||||
if (match) {
|
||||
const side = match.players.a.accountId === session.accountId ? 'a' : 'b'
|
||||
return { ticketId: existingTicket.id, status: 'matched', side, match: pvpSnapshot(match) }
|
||||
}
|
||||
}
|
||||
|
||||
const opponent = [...pvpQueue.values()]
|
||||
.filter((ticket) =>
|
||||
!ticket.matchId
|
||||
&& ticket.contentType === contentType
|
||||
&& ticket.startStage === startStage
|
||||
&& ticket.accountId !== session.accountId
|
||||
)
|
||||
.sort((left, right) => left.createdAt - right.createdAt)[0]
|
||||
const player = pvpPlayerInfo(session)
|
||||
if (opponent) {
|
||||
const match = createPvpMatch(contentType, startStage, {
|
||||
a: { side: 'a', ...opponent.player },
|
||||
b: { side: 'b', ...player },
|
||||
}, now)
|
||||
opponent.matchId = match.id
|
||||
opponent.updatedAt = now
|
||||
const ticketId = randomBytes(12).toString('base64url')
|
||||
pvpQueue.set(ticketId, {
|
||||
id: ticketId,
|
||||
accountId: session.accountId,
|
||||
characterId: session.characterId,
|
||||
contentType,
|
||||
startStage,
|
||||
player,
|
||||
matchId: match.id,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
return { ticketId, status: 'matched', side: 'b', match: pvpSnapshot(match) }
|
||||
}
|
||||
|
||||
if (existingTicket) {
|
||||
existingTicket.updatedAt = now
|
||||
return { ticketId: existingTicket.id, status: 'waiting' }
|
||||
}
|
||||
|
||||
const ticketId = randomBytes(12).toString('base64url')
|
||||
pvpQueue.set(ticketId, {
|
||||
id: ticketId,
|
||||
accountId: session.accountId,
|
||||
characterId: session.characterId,
|
||||
contentType,
|
||||
startStage,
|
||||
player,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
return { ticketId, status: 'waiting' }
|
||||
}
|
||||
|
||||
function checkPvpQueue(session, ticketId) {
|
||||
cleanupPvpMemory()
|
||||
const ticket = pvpQueue.get(ticketId)
|
||||
if (!ticket || ticket.accountId !== session.accountId) {
|
||||
const error = new Error('PvP queue ticket not found.')
|
||||
error.status = 404
|
||||
throw error
|
||||
}
|
||||
ticket.updatedAt = Date.now()
|
||||
if (!ticket.matchId) return { ticketId, status: 'waiting' }
|
||||
const match = pvpMatches.get(ticket.matchId)
|
||||
if (!match) return { ticketId, status: 'waiting' }
|
||||
const side = match.players.a.accountId === session.accountId ? 'a' : 'b'
|
||||
return { ticketId, status: 'matched', side, match: pvpSnapshot(match) }
|
||||
}
|
||||
|
||||
function cancelPvpQueue(session, ticketId) {
|
||||
const ticket = pvpQueue.get(ticketId)
|
||||
if (ticket && ticket.accountId === session.accountId && !ticket.matchId) {
|
||||
pvpQueue.delete(ticketId)
|
||||
}
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
function requirePvpMatchForSession(session, matchId) {
|
||||
cleanupPvpMemory()
|
||||
const match = pvpMatches.get(matchId)
|
||||
if (!match) {
|
||||
const error = new Error('PvP match not found.')
|
||||
error.status = 404
|
||||
throw error
|
||||
}
|
||||
const side = match.players.a.accountId === session.accountId ? 'a'
|
||||
: match.players.b.accountId === session.accountId ? 'b'
|
||||
: null
|
||||
if (!side) {
|
||||
const error = new Error('That PvP match belongs to another account.')
|
||||
error.status = 403
|
||||
throw error
|
||||
}
|
||||
return { match, side }
|
||||
}
|
||||
|
||||
function updatePvpMatchState(session, matchId, payload) {
|
||||
const { match, side } = requirePvpMatchForSession(session, matchId)
|
||||
const status = ['playing', 'upgrade-choice', 'won', 'lost'].includes(payload.status)
|
||||
? payload.status
|
||||
: 'playing'
|
||||
const progress = {
|
||||
stage: validatePvpStartStage(payload.stage),
|
||||
encounterIndex: Math.max(0, Math.floor(Number(payload.encounterIndex) || 0)),
|
||||
encountersCleared: Math.max(0, Math.floor(Number(payload.encountersCleared) || 0)),
|
||||
enemyHealth: Math.max(0, Number(payload.enemyHealth) || 0),
|
||||
alive: Boolean(payload.alive),
|
||||
elapsedTicks: Math.max(0, Math.floor(Number(payload.elapsedTicks) || 0)),
|
||||
}
|
||||
match.states[side] = payload.state ?? null
|
||||
match.statuses[side] = status
|
||||
match.progress[side] = progress
|
||||
match.updatedAt = Date.now()
|
||||
return pvpSnapshot(match)
|
||||
}
|
||||
|
||||
function submitPvpUpgradeChoice(session, matchId, payload) {
|
||||
const { match, side } = requirePvpMatchForSession(session, matchId)
|
||||
const encounterIndex = Math.max(0, Math.floor(Number(payload.encounterIndex) || 0))
|
||||
if (!match.upgradeChoices[side]) match.upgradeChoices[side] = {}
|
||||
match.upgradeChoices[side][String(encounterIndex)] = {
|
||||
encounterIndex,
|
||||
buffId: String(payload.buffId ?? ''),
|
||||
debuffId: String(payload.debuffId ?? ''),
|
||||
}
|
||||
match.updatedAt = Date.now()
|
||||
return pvpSnapshot(match)
|
||||
}
|
||||
|
||||
function requestPvpRematch(session, matchId) {
|
||||
const { match, side } = requirePvpMatchForSession(session, matchId)
|
||||
if (match.nextMatchId) {
|
||||
const nextMatch = pvpMatches.get(match.nextMatchId)
|
||||
if (nextMatch) return { status: 'matched', side, match: pvpSnapshot(nextMatch) }
|
||||
}
|
||||
match.rematchRequests = match.rematchRequests ?? {}
|
||||
match.rematchRequests[side] = true
|
||||
match.updatedAt = Date.now()
|
||||
const opponentSide = side === 'a' ? 'b' : 'a'
|
||||
if (!match.rematchRequests[opponentSide]) {
|
||||
return { status: 'waiting', match: pvpSnapshot(match), side }
|
||||
}
|
||||
const nextMatch = createPvpMatch(
|
||||
match.contentType,
|
||||
match.startStage,
|
||||
{
|
||||
a: match.players.a,
|
||||
b: match.players.b,
|
||||
},
|
||||
Date.now(),
|
||||
)
|
||||
match.nextMatchId = nextMatch.id
|
||||
match.updatedAt = Date.now()
|
||||
return { status: 'matched', side, match: pvpSnapshot(nextMatch) }
|
||||
}
|
||||
|
||||
export function gameApiPlugin() {
|
||||
return {
|
||||
name: 'ashen-halls-game-api',
|
||||
@@ -2687,7 +2935,7 @@ export async function handleApiRequest(request, response, next) {
|
||||
|
||||
try {
|
||||
const ip = requestIp(request)
|
||||
consumeRateLimit(`api:${ip}`, 240, 60 * 1000)
|
||||
consumeRateLimit(`api:${ip}`, 900, 60 * 1000)
|
||||
database.prepare(`
|
||||
DELETE FROM sessions WHERE expires_at <= CURRENT_TIMESTAMP
|
||||
`).run()
|
||||
@@ -2696,6 +2944,11 @@ export async function handleApiRequest(request, response, next) {
|
||||
return
|
||||
}
|
||||
|
||||
if (request.url === '/api/catalog' && request.method === 'GET') {
|
||||
sendJson(response, 200, catalogPayload(getProfile(database, 1)))
|
||||
return
|
||||
}
|
||||
|
||||
const session = requireSession(database, request)
|
||||
|
||||
if (request.url === '/api/profile/sync-save' && request.method === 'GET') {
|
||||
@@ -2721,6 +2974,49 @@ export async function handleApiRequest(request, response, next) {
|
||||
return
|
||||
}
|
||||
|
||||
if (request.url === '/api/pvp/queue' && request.method === 'POST') {
|
||||
const payload = await readJson(request)
|
||||
sendJson(response, 200, joinPvpQueue(session, payload))
|
||||
return
|
||||
}
|
||||
|
||||
const pvpQueueTicket = request.url.match(/^\/api\/pvp\/queue\/([A-Za-z0-9_-]+)$/)
|
||||
if (pvpQueueTicket && request.method === 'GET') {
|
||||
sendJson(response, 200, checkPvpQueue(session, pvpQueueTicket[1]))
|
||||
return
|
||||
}
|
||||
|
||||
if (pvpQueueTicket && request.method === 'DELETE') {
|
||||
sendJson(response, 200, cancelPvpQueue(session, pvpQueueTicket[1]))
|
||||
return
|
||||
}
|
||||
|
||||
const pvpMatchState = request.url.match(/^\/api\/pvp\/matches\/([A-Za-z0-9_-]+)\/state$/)
|
||||
if (pvpMatchState && request.method === 'POST') {
|
||||
const payload = await readJson(request, 128 * 1024)
|
||||
sendJson(response, 200, updatePvpMatchState(session, pvpMatchState[1], payload))
|
||||
return
|
||||
}
|
||||
|
||||
const pvpUpgradeChoice = request.url.match(/^\/api\/pvp\/matches\/([A-Za-z0-9_-]+)\/upgrade-choice$/)
|
||||
if (pvpUpgradeChoice && request.method === 'POST') {
|
||||
const payload = await readJson(request)
|
||||
sendJson(response, 200, submitPvpUpgradeChoice(session, pvpUpgradeChoice[1], payload))
|
||||
return
|
||||
}
|
||||
|
||||
const pvpRematch = request.url.match(/^\/api\/pvp\/matches\/([A-Za-z0-9_-]+)\/rematch$/)
|
||||
if (pvpRematch && request.method === 'POST') {
|
||||
sendJson(response, 200, requestPvpRematch(session, pvpRematch[1]))
|
||||
return
|
||||
}
|
||||
|
||||
const pvpMatch = request.url.match(/^\/api\/pvp\/matches\/([A-Za-z0-9_-]+)$/)
|
||||
if (pvpMatch && request.method === 'GET') {
|
||||
sendJson(response, 200, pvpSnapshot(requirePvpMatchForSession(session, pvpMatch[1]).match))
|
||||
return
|
||||
}
|
||||
|
||||
const dungeonCompletion = request.url.match(/^\/api\/dungeons\/(\d+)\/complete$/)
|
||||
if (dungeonCompletion && request.method === 'POST') {
|
||||
const payload = await readJson(request)
|
||||
|
||||
+381
-48
@@ -621,6 +621,7 @@ textarea:focus-visible,
|
||||
min-height: 120px;
|
||||
outline: 2px solid #3a3944;
|
||||
padding: 14px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.dual-top-member.selected {
|
||||
@@ -773,7 +774,7 @@ textarea:focus-visible,
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
height: calc(100dvh - 20px);
|
||||
grid-template-rows: auto 1fr;
|
||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
@@ -803,6 +804,84 @@ textarea:focus-visible,
|
||||
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 {
|
||||
background:
|
||||
linear-gradient(rgba(8, 9, 12, 0.91), rgba(8, 9, 12, 0.91)),
|
||||
@@ -815,6 +894,10 @@ textarea:focus-visible,
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.pvp-opponent-bottom-display {
|
||||
grid-template-rows: auto auto minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.dual-controls-header,
|
||||
.dual-controls-resource,
|
||||
.dual-controls-targets,
|
||||
@@ -837,6 +920,109 @@ textarea:focus-visible,
|
||||
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 {
|
||||
color: var(--muted);
|
||||
font-family: 'Press Start 2P', monospace;
|
||||
@@ -949,6 +1135,10 @@ textarea:focus-visible,
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.dual-controls-header small {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.dual-controls-progress {
|
||||
font-size: 6px;
|
||||
}
|
||||
@@ -1022,6 +1212,67 @@ textarea:focus-visible,
|
||||
.dual-controls-spells .spell small {
|
||||
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 {
|
||||
@@ -1685,7 +1936,7 @@ h2 {
|
||||
.equipment-screen .crafting-panel,
|
||||
.talent-screen .talent-tree,
|
||||
.talent-screen .spell-effect-layout {
|
||||
flex: 1;
|
||||
flex: 0 0 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
@@ -1712,7 +1963,8 @@ h2 {
|
||||
.customize-screen > .embedded-screen {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.customize-screen .loadout-editor {
|
||||
@@ -3519,6 +3771,7 @@ h2 {
|
||||
|
||||
.spell-effect-layout {
|
||||
display: grid;
|
||||
flex: 0 0 auto;
|
||||
gap: 14px;
|
||||
grid-template-columns: 220px minmax(0, 1fr);
|
||||
margin-top: 17px;
|
||||
@@ -3537,6 +3790,7 @@ h2 {
|
||||
}
|
||||
|
||||
.effect-slots-panel {
|
||||
align-content: start;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
grid-auto-rows: minmax(76px, auto);
|
||||
@@ -3640,12 +3894,12 @@ h2 {
|
||||
.effect-pool {
|
||||
align-content: start;
|
||||
display: grid;
|
||||
flex: 1;
|
||||
flex: 0 0 auto;
|
||||
gap: 10px;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
grid-template-rows: repeat(2, 62px);
|
||||
margin-top: 12px;
|
||||
min-height: 0;
|
||||
min-height: 134px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -4703,7 +4957,7 @@ h2 {
|
||||
.customize-tabs {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
@@ -5252,22 +5506,6 @@ h2 {
|
||||
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 {
|
||||
min-height: 66px;
|
||||
padding: 7px;
|
||||
@@ -5302,6 +5540,7 @@ h2 {
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.floating-heal {
|
||||
@@ -5848,27 +6087,17 @@ h2 {
|
||||
.pvp-board {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(210px, 0.68fr) minmax(0, 1fr);
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
grid-template-rows: minmax(0, 1fr) auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.pvp-side,
|
||||
.pvp-middle-panel {
|
||||
.pvp-side {
|
||||
gap: 8px;
|
||||
min-height: 0;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.pvp-vertical-spell-bar,
|
||||
.pvp-vertical-spell-bar.six-slots {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.pvp-vertical-spell-bar .spell {
|
||||
min-height: 58px;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.pvp-screen-tools {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
@@ -5879,18 +6108,41 @@ h2 {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.pvp-resource-wrap {
|
||||
color: #82bfff;
|
||||
min-width: 150px;
|
||||
text-align: right;
|
||||
width: min(170px, 100%);
|
||||
.pvp-side-bars {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
min-width: min(320px, 45%);
|
||||
width: min(360px, 48%);
|
||||
}
|
||||
|
||||
.pvp-clear-wrap,
|
||||
.pvp-resource-wrap {
|
||||
color: var(--muted);
|
||||
font-family: 'Press Start 2P', monospace;
|
||||
font-size: 7px;
|
||||
text-align: right;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.pvp-clear-wrap > span,
|
||||
.pvp-resource-wrap > span {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.pvp-clear-wrap .bar,
|
||||
.pvp-resource-wrap .bar {
|
||||
height: 13px;
|
||||
}
|
||||
|
||||
.pvp-clear-wrap {
|
||||
color: #ff8d9a;
|
||||
}
|
||||
|
||||
.pvp-resource-wrap {
|
||||
color: #82bfff;
|
||||
}
|
||||
|
||||
.pvp-side .party-member,
|
||||
.pvp-side .party-member > div,
|
||||
.pvp-side .party-member > small {
|
||||
@@ -5908,7 +6160,7 @@ h2 {
|
||||
}
|
||||
|
||||
.pvp-side .pvp-party-grid.raid .party-member {
|
||||
min-height: 62px;
|
||||
min-height: 96px;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
@@ -5945,6 +6197,29 @@ h2 {
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.pvp-side .member-health {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.pvp-side .member-health .health-text {
|
||||
align-items: center;
|
||||
color: #fff3c7;
|
||||
display: flex;
|
||||
font-family: 'Press Start 2P', monospace;
|
||||
font-size: 7px;
|
||||
font-style: normal;
|
||||
inset: 0;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
text-shadow: 1px 1px 0 #08090c, -1px -1px 0 #08090c;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.pvp-side .party-member .member-header small {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.pvp-side .member-effects {
|
||||
margin-top: 4px;
|
||||
}
|
||||
@@ -5963,26 +6238,61 @@ h2 {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.pvp-middle-panel .encounter-header h2 {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.pvp-middle-panel .encounter-header small,
|
||||
.pvp-enemy-race small {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.pvp-middle-panel .roguelike-upgrade-list,
|
||||
.pvp-side .roguelike-upgrade-list {
|
||||
font-size: 12px;
|
||||
line-height: 1.1;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.pvp-bottom-spell-bar {
|
||||
background: var(--panel);
|
||||
border: 3px solid #0c0d11;
|
||||
box-shadow: 4px 4px 0 #08090c;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
grid-column: 1 / -1;
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
outline: 2px solid var(--edge);
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.pvp-bottom-spell-bar .spell {
|
||||
align-items: center;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
grid-template-columns: auto auto minmax(0, 1fr) auto;
|
||||
min-height: 58px;
|
||||
padding: 7px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.pvp-bottom-spell-bar .spell-icon {
|
||||
height: 34px;
|
||||
margin: 0;
|
||||
width: 34px;
|
||||
}
|
||||
|
||||
.pvp-bottom-spell-bar .spell strong {
|
||||
font-size: 13px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pvp-bottom-spell-bar .spell small {
|
||||
font-size: 13px;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pvp-choice-columns {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
@@ -6017,6 +6327,29 @@ h2 {
|
||||
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 {
|
||||
gap: 10px;
|
||||
margin-top: 0;
|
||||
|
||||
@@ -1375,6 +1375,7 @@ export function CombatScreen({
|
||||
encounterIndex,
|
||||
encounterCount: encounters.length,
|
||||
party,
|
||||
floatingTexts,
|
||||
partySize: dungeon.partySize,
|
||||
selectedId,
|
||||
log,
|
||||
@@ -1430,6 +1431,7 @@ export function CombatScreen({
|
||||
selectedId,
|
||||
spells,
|
||||
freeCastReady,
|
||||
floatingTexts,
|
||||
roguelikeUpgrades,
|
||||
speedMultiplier,
|
||||
status,
|
||||
@@ -1521,7 +1523,6 @@ export function CombatScreen({
|
||||
<div className="bar member-health">
|
||||
<span style={{ width: `${(member.health / 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 className="floating-combat-texts" aria-hidden="true">
|
||||
{floatingTexts
|
||||
@@ -1595,6 +1596,7 @@ export function CombatScreen({
|
||||
{dualScreenEnabled && (
|
||||
<DualScreenTopCombat
|
||||
state={dualScreenState}
|
||||
onCastSpell={castSpell}
|
||||
onSelectTarget={setSelectedTargetId}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -23,14 +23,26 @@ import {
|
||||
} from '../dualScreen'
|
||||
import {
|
||||
loadPvpRoguelikeCheckpoint,
|
||||
cancelPvpQueue,
|
||||
checkPvpQueue,
|
||||
joinPvpQueue,
|
||||
loadPvpMatch,
|
||||
publishPvpMatchState,
|
||||
requestPvpRematch,
|
||||
randomCpuDifficulty,
|
||||
recordCpuPvpLeaderboard,
|
||||
recordPvpRoguelikeCheckpoint,
|
||||
submitPvpUpgradeChoice,
|
||||
type CpuDifficulty,
|
||||
type PvpMatchSnapshot,
|
||||
type PvpMatchSide,
|
||||
type PvpRematchResponse,
|
||||
type PvpContentType,
|
||||
type PvpUpgradeChoicePayload,
|
||||
} from '../pvpRoguelike'
|
||||
|
||||
const TICK_MS = 700
|
||||
const UPGRADE_CHOICE_SECONDS = 10
|
||||
|
||||
type BossMechanic =
|
||||
| 'party-pulse'
|
||||
@@ -99,6 +111,14 @@ type PvpRunSummary = {
|
||||
loot: Array<NonNullable<DungeonReward['bonusItem']>>
|
||||
}
|
||||
|
||||
type LivePvpMatch = {
|
||||
id: string
|
||||
side: PvpMatchSide
|
||||
opponentSide: PvpMatchSide
|
||||
opponentName: string
|
||||
opponentClassName: string
|
||||
}
|
||||
|
||||
const BOSS_MECHANICS: BossMechanic[] = [
|
||||
'party-pulse',
|
||||
'searing-mark',
|
||||
@@ -449,6 +469,10 @@ export function PvPRoguelikeScreen({
|
||||
const [speedMultiplier, setSpeedMultiplier] = useState<1 | 2>(1)
|
||||
const [elapsedTicks, setElapsedTicks] = useState(0)
|
||||
const [cpuDifficulty, setCpuDifficulty] = useState<CpuDifficulty | null>(null)
|
||||
const [liveMatch, setLiveMatch] = useState<LivePvpMatch | null>(null)
|
||||
const [liveUpgradePending, setLiveUpgradePending] = useState(false)
|
||||
const [rematchRequested, setRematchRequested] = useState(false)
|
||||
const [rematchMessage, setRematchMessage] = useState('')
|
||||
const [queueMessage, setQueueMessage] = useState('')
|
||||
const [log, setLog] = useState<CombatLogEntry[]>([{ id: 1, text: 'Queueing opponent...', tone: 'system' }])
|
||||
const [reward, setReward] = useState<DungeonReward | null>(null)
|
||||
@@ -460,6 +484,7 @@ export function PvPRoguelikeScreen({
|
||||
const [playerDebuffChoices, setPlayerDebuffChoices] = useState<Array<Choice<OpponentDebuffId>>>([])
|
||||
const [selectedBuff, setSelectedBuff] = useState<Choice<SelfBuffId> | null>(null)
|
||||
const [selectedDebuff, setSelectedDebuff] = useState<Choice<OpponentDebuffId> | null>(null)
|
||||
const [upgradeTimeLeft, setUpgradeTimeLeft] = useState(UPGRADE_CHOICE_SECONDS)
|
||||
const [encountersCleared, setEncountersCleared] = useState(0)
|
||||
const [paused, setPaused] = useState(false)
|
||||
const [targetGroup, setTargetGroup] = useState<0 | 1 | 2>(0)
|
||||
@@ -471,6 +496,15 @@ export function PvPRoguelikeScreen({
|
||||
const cpuDefeatedRef = useRef(false)
|
||||
const playerClearedEncounterRef = useRef(-1)
|
||||
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 playerRef = useRef(playerSide)
|
||||
const cpuRef = useRef(cpuSide)
|
||||
@@ -484,6 +518,7 @@ export function PvPRoguelikeScreen({
|
||||
? Math.max(encountersCleared, encounterIndex + 1)
|
||||
: encountersCleared
|
||||
const cpuBehavior = cpuDifficulty ? CPU_BEHAVIOR[cpuDifficulty] : CPU_BEHAVIOR[1]
|
||||
const opponentLabel = liveMatch ? liveMatch.opponentName : `CPU ${cpuDifficulty ?? 1}`
|
||||
const activeSpellEffects = useMemo(
|
||||
() => new Set(
|
||||
gameClass.talents
|
||||
@@ -604,6 +639,80 @@ export function PvPRoguelikeScreen({
|
||||
: null)
|
||||
}, [opponentDebuffChoicesCatalog, selfBuffChoicesCatalog])
|
||||
|
||||
const startLiveMatch = useCallback((
|
||||
match: PvpMatchSnapshot<SideState>,
|
||||
side: PvpMatchSide,
|
||||
message?: string,
|
||||
) => {
|
||||
const matchStartStage = match.startStage
|
||||
const firstSegment = buildEncounterSegment(encounterPoolRef.current, matchStartStage, contentType)
|
||||
const firstEncounter = firstSegment[0]
|
||||
const basePlayer = starterSide(partyTemplate, maxResource)
|
||||
const opponentSide: PvpMatchSide = side === 'a' ? 'b' : 'a'
|
||||
const opponent = match.players[opponentSide]
|
||||
const baseOpponent = starterSide(
|
||||
cpuPartyTemplate.map((member) => ({
|
||||
...member,
|
||||
name: member.id === 'mira' ? opponent.characterName : member.name,
|
||||
})),
|
||||
maxResource,
|
||||
)
|
||||
basePlayer.enemyHealth = firstEncounter.maxHealth
|
||||
baseOpponent.enemyHealth = firstEncounter.maxHealth
|
||||
const nextLiveMatch: LivePvpMatch = {
|
||||
id: match.id,
|
||||
side,
|
||||
opponentSide,
|
||||
opponentName: opponent.characterName,
|
||||
opponentClassName: opponent.className,
|
||||
}
|
||||
playerRef.current = basePlayer
|
||||
cpuRef.current = baseOpponent
|
||||
liveMatchRef.current = nextLiveMatch
|
||||
nextLogId.current = 2
|
||||
playerClearedEncounterRef.current = -1
|
||||
queuedMatchRef.current = true
|
||||
bossRewardClaimedRef.current = new Set()
|
||||
setEncounters(firstSegment)
|
||||
setEncounterIndex(0)
|
||||
setCheckpointStage(matchStartStage)
|
||||
setStartStage(matchStartStage)
|
||||
setStage(matchStartStage)
|
||||
setElapsedTicks(0)
|
||||
setStatus('playing')
|
||||
setPlayerSide(basePlayer)
|
||||
setCpuSide(baseOpponent)
|
||||
setSelectedTargetId(partyTemplate[0].id)
|
||||
setPlayerBuffChoices([])
|
||||
setPlayerDebuffChoices([])
|
||||
setSelectedBuff(null)
|
||||
setSelectedDebuff(null)
|
||||
setUpgradeTimeLeft(UPGRADE_CHOICE_SECONDS)
|
||||
upgradeChoiceEndsAtRef.current = 0
|
||||
autoSubmittedUpgradeRef.current = false
|
||||
setEncountersCleared(0)
|
||||
setPaused(false)
|
||||
setTargetGroup(0)
|
||||
setReward(null)
|
||||
setRunSummary(createEmptyPvpRunSummary())
|
||||
setRewardError('')
|
||||
setShowEndLog(false)
|
||||
setFloatingTexts([])
|
||||
setCpuDifficulty(null)
|
||||
setLiveMatch(nextLiveMatch)
|
||||
setLiveUpgradePending(false)
|
||||
pendingLiveUpgradeRef.current = null
|
||||
loggedOpponentDoneRef.current = false
|
||||
recordedRunRef.current = false
|
||||
rewardClaimedRef.current = false
|
||||
cpuDefeatedRef.current = false
|
||||
setRematchRequested(false)
|
||||
setRematchMessage('')
|
||||
const logText = message ?? `${opponent.characterName} found. Stage ${matchStartStage} begins.`
|
||||
setQueueMessage(logText)
|
||||
setLog([{ id: 1, text: logText, tone: 'system' }])
|
||||
}, [contentType, cpuPartyTemplate, maxResource, partyTemplate, setSelectedTargetId])
|
||||
|
||||
const startMatch = useCallback((nextStartStage?: number) => {
|
||||
const matchStartStage = nextStartStage ?? loadPvpRoguelikeCheckpoint(profile.character.id, contentType)
|
||||
const firstSegment = buildEncounterSegment(encounterPoolRef.current, matchStartStage, contentType)
|
||||
@@ -632,6 +741,9 @@ export function PvPRoguelikeScreen({
|
||||
setPlayerDebuffChoices([])
|
||||
setSelectedBuff(null)
|
||||
setSelectedDebuff(null)
|
||||
setUpgradeTimeLeft(UPGRADE_CHOICE_SECONDS)
|
||||
upgradeChoiceEndsAtRef.current = 0
|
||||
autoSubmittedUpgradeRef.current = false
|
||||
setEncountersCleared(0)
|
||||
setPaused(false)
|
||||
setTargetGroup(0)
|
||||
@@ -641,34 +753,142 @@ export function PvPRoguelikeScreen({
|
||||
setShowEndLog(false)
|
||||
setFloatingTexts([])
|
||||
setCpuDifficulty(null)
|
||||
setLiveMatch(null)
|
||||
liveMatchRef.current = null
|
||||
setLiveUpgradePending(false)
|
||||
pendingLiveUpgradeRef.current = null
|
||||
loggedOpponentDoneRef.current = false
|
||||
setRematchRequested(false)
|
||||
setRematchMessage('')
|
||||
recordedRunRef.current = false
|
||||
rewardClaimedRef.current = false
|
||||
cpuDefeatedRef.current = false
|
||||
if (gameMode === 'offline') {
|
||||
const randomCpu = randomCpuDifficulty()
|
||||
setQueueMessage(`Offline mode. CPU ${randomCpu} enters at stage ${matchStartStage}.`)
|
||||
const beginCpuMatch = (randomCpu: CpuDifficulty, message: string) => {
|
||||
liveMatchRef.current = null
|
||||
setLiveMatch(null)
|
||||
setCpuDifficulty(randomCpu)
|
||||
setLog([{ id: 1, text: `Offline mode. CPU ${randomCpu} enters at stage ${matchStartStage}.`, tone: 'system' }])
|
||||
const timer = window.setTimeout(() => {
|
||||
setQueueMessage(message)
|
||||
setLog([{ id: 1, text: message, tone: 'system' }])
|
||||
setStatus('playing')
|
||||
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)
|
||||
return () => window.clearTimeout(timer)
|
||||
}
|
||||
setQueueMessage(`Searching queue. Stage ${matchStartStage} start ready.`)
|
||||
setLog([{ id: 1, text: `Searching queue. Stage ${matchStartStage} start ready.`, tone: 'system' }])
|
||||
const timer = window.setTimeout(() => {
|
||||
|
||||
let cancelled = false
|
||||
let ticketId = ''
|
||||
let pollTimer: number | undefined
|
||||
setQueueMessage(`Searching queue for 5s. Stage ${matchStartStage} start ready.`)
|
||||
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]
|
||||
startLiveMatch(match, side, `${opponent.characterName} found. Stage ${match.startStage} begins.`)
|
||||
}
|
||||
const fallbackTimer = window.setTimeout(() => {
|
||||
if (cancelled || liveMatchRef.current) return
|
||||
cancelled = true
|
||||
if (ticketId) cancelPvpQueue(ticketId).catch(() => undefined)
|
||||
const randomCpu = randomCpuDifficulty()
|
||||
setCpuDifficulty(randomCpu)
|
||||
setQueueMessage(`No queued player found. CPU ${randomCpu} steps in.`)
|
||||
setStatus('playing')
|
||||
addLog(`No queued player found. CPU ${randomCpu} steps in at stage ${matchStartStage}.`, 'system')
|
||||
}, 1400)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [addLog, contentType, cpuPartyTemplate, gameMode, maxResource, partyTemplate, profile.character.id, setSelectedTargetId])
|
||||
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, startLiveMatch])
|
||||
|
||||
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]
|
||||
const opponentAlive = snapshot.progress[liveMatch.opponentSide]?.alive
|
||||
if ((opponentStatus === 'lost' || opponentAlive === false) && !loggedOpponentDoneRef.current && status !== 'won' && status !== 'lost') {
|
||||
loggedOpponentDoneRef.current = true
|
||||
cpuDefeatedRef.current = true
|
||||
finishRoguelikeRun()
|
||||
setStatus('won')
|
||||
addLog(`${liveMatch.opponentName} fell. Match complete.`, '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((
|
||||
current: SideState,
|
||||
setCurrent: React.Dispatch<React.SetStateAction<SideState>>,
|
||||
@@ -981,6 +1201,9 @@ export function PvPRoguelikeScreen({
|
||||
}, [activeSpellEffects, addFloatingHeal, elapsedTicks, maxResource])
|
||||
|
||||
const beginUpgradePhase = useCallback(() => {
|
||||
upgradeChoiceEndsAtRef.current = Date.now() + UPGRADE_CHOICE_SECONDS * 1000
|
||||
autoSubmittedUpgradeRef.current = false
|
||||
setUpgradeTimeLeft(UPGRADE_CHOICE_SECONDS)
|
||||
setPlayerBuffChoices(chooseRandom(selfBuffChoicesCatalog, 3))
|
||||
setPlayerDebuffChoices(chooseRandom(opponentDebuffChoicesCatalog, 3))
|
||||
setSelectedBuff(null)
|
||||
@@ -992,9 +1215,9 @@ export function PvPRoguelikeScreen({
|
||||
if (status !== 'playing' || paused || !encounter) return
|
||||
const timer = window.setInterval(() => {
|
||||
setElapsedTicks((value) => value + 1)
|
||||
cpuTakeTurn()
|
||||
if (!liveMatch) cpuTakeTurn()
|
||||
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) {
|
||||
playerClearedEncounterRef.current = encounterIndex
|
||||
setEncountersCleared((value) => value + 1)
|
||||
@@ -1024,15 +1247,18 @@ export function PvPRoguelikeScreen({
|
||||
addLog('Your party fell first.', 'danger')
|
||||
return
|
||||
}
|
||||
if (!nextCpuAlive && !cpuDefeatedRef.current) {
|
||||
if (!liveMatch && !nextCpuAlive && !cpuDefeatedRef.current) {
|
||||
cpuDefeatedRef.current = true
|
||||
addLog(`CPU ${cpuDifficulty ?? 1} fell. Finish the boss for XP.`, 'loot')
|
||||
finishRoguelikeRun()
|
||||
setStatus('won')
|
||||
addLog(`CPU ${cpuDifficulty ?? 1} fell. Match complete.`, 'loot')
|
||||
return
|
||||
}
|
||||
if (nextPlayer.enemyHealth <= 0) {
|
||||
if (encounter.isBoss && cpuDefeatedRef.current) {
|
||||
finishRoguelikeRun()
|
||||
setStatus('won')
|
||||
addLog('CPU defeated. Match complete.', 'loot')
|
||||
addLog(`${liveMatch ? liveMatch.opponentName : `CPU ${cpuDifficulty ?? 1}`} defeated. Match complete.`, 'loot')
|
||||
return
|
||||
}
|
||||
addLog(`${encounter.enemyName} cleared. Choose your next edge.`, 'loot')
|
||||
@@ -1040,7 +1266,7 @@ export function PvPRoguelikeScreen({
|
||||
}
|
||||
}, TICK_MS / speedMultiplier)
|
||||
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(() => {
|
||||
if ((status !== 'won' && status !== 'lost') || recordedRunRef.current || !cpuDifficulty) return
|
||||
@@ -1056,6 +1282,46 @@ export function PvPRoguelikeScreen({
|
||||
})
|
||||
}, [contentType, cpuDifficulty, finalEncountersCleared, profile.character.className, profile.character.name, status])
|
||||
|
||||
const handleRematch = useCallback(() => {
|
||||
if (!liveMatch || rematchRequested) return
|
||||
let cancelled = false
|
||||
let attempts = 0
|
||||
setRematchRequested(true)
|
||||
setRematchMessage(`Waiting for ${liveMatch.opponentName} to rematch...`)
|
||||
const handleResponse = (result: PvpRematchResponse<SideState>) => {
|
||||
if (cancelled) return
|
||||
if (result.status === 'matched' && result.match && result.side) {
|
||||
startLiveMatch(result.match, result.side, `Rematch against ${liveMatch.opponentName} begins.`)
|
||||
return
|
||||
}
|
||||
attempts += 1
|
||||
if (attempts >= 180) {
|
||||
setRematchRequested(false)
|
||||
setRematchMessage('Rematch expired.')
|
||||
return
|
||||
}
|
||||
window.setTimeout(pollRematch, 700)
|
||||
}
|
||||
const pollRematch = () => {
|
||||
requestPvpRematch<SideState>(liveMatch.id)
|
||||
.then(handleResponse)
|
||||
.catch((reason: unknown) => {
|
||||
if (cancelled) return
|
||||
attempts += 1
|
||||
if (attempts >= 10) {
|
||||
setRematchRequested(false)
|
||||
setRematchMessage(reason instanceof Error ? reason.message : 'Unable to request rematch.')
|
||||
return
|
||||
}
|
||||
window.setTimeout(pollRematch, 900)
|
||||
})
|
||||
}
|
||||
pollRematch()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [liveMatch, rematchRequested, startLiveMatch])
|
||||
|
||||
useEffect(() => {
|
||||
if (status !== 'upgrade-choice') return
|
||||
window.requestAnimationFrame(() => focusFirstControl())
|
||||
@@ -1066,8 +1332,117 @@ export function PvPRoguelikeScreen({
|
||||
window.requestAnimationFrame(() => focusFirstControl())
|
||||
}, [paused])
|
||||
|
||||
const confirmUpgradeChoices = useCallback(() => {
|
||||
if (!selectedBuff || !selectedDebuff || !cpuDifficulty) return
|
||||
const confirmUpgradeChoices = useCallback((
|
||||
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 cpuDebuffChoices = chooseRandom(opponentDebuffChoicesCatalog, 3)
|
||||
const cpuBuff = selectCpuChoice(cpuBuffChoices, cpuDifficulty, (choice) => scoreSelfBuff(choice, starterSpells))
|
||||
@@ -1075,17 +1450,17 @@ export function PvPRoguelikeScreen({
|
||||
|
||||
let nextPlayer = {
|
||||
...playerRef.current,
|
||||
buffs: [...playerRef.current.buffs, selectedBuff.id],
|
||||
buffs: [...playerRef.current.buffs, chosenBuff.id],
|
||||
}
|
||||
let nextCpu = {
|
||||
...cpuRef.current,
|
||||
buffs: [...cpuRef.current.buffs, cpuBuff.id],
|
||||
}
|
||||
|
||||
if (selectedDebuff.id === 'opp-purge-random-buff') {
|
||||
if (chosenDebuff.id === 'opp-purge-random-buff') {
|
||||
nextCpu = removeRandomBuff(nextCpu)
|
||||
} else {
|
||||
nextCpu = { ...nextCpu, debuffs: [...nextCpu.debuffs, selectedDebuff.id] }
|
||||
nextCpu = { ...nextCpu, debuffs: [...nextCpu.debuffs, chosenDebuff.id] }
|
||||
}
|
||||
|
||||
if (cpuDebuff.id === 'opp-purge-random-buff') {
|
||||
@@ -1153,8 +1528,41 @@ export function PvPRoguelikeScreen({
|
||||
cpuRef.current = nextCpu
|
||||
setElapsedTicks(0)
|
||||
setStatus('playing')
|
||||
addLog(`You chose ${selectedBuff.name} and ${selectedDebuff.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(`You chose ${chosenBuff.name} and ${chosenDebuff.name}. CPU ${cpuDifficulty} chose ${cpuBuff.name} and ${cpuDebuff.name}.`, 'system')
|
||||
}, [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) => {
|
||||
if (action === 'toggleSpeed') {
|
||||
@@ -1212,6 +1620,16 @@ export function PvPRoguelikeScreen({
|
||||
encounterIndex,
|
||||
encounterCount: encounters.length,
|
||||
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,
|
||||
selectedId,
|
||||
log,
|
||||
@@ -1236,6 +1654,12 @@ export function PvPRoguelikeScreen({
|
||||
}), [
|
||||
bindings,
|
||||
controllerIconStyle,
|
||||
cpuDifficulty,
|
||||
cpuSide.buffs,
|
||||
cpuSide.debuffs,
|
||||
cpuSide.enemyHealth,
|
||||
cpuSide.party,
|
||||
cpuSide.resource,
|
||||
directPartyTargeting,
|
||||
encounter.description,
|
||||
encounter.enemyName,
|
||||
@@ -1243,10 +1667,15 @@ export function PvPRoguelikeScreen({
|
||||
encounter.maxHealth,
|
||||
encounterIndex,
|
||||
encounters.length,
|
||||
floatingTexts,
|
||||
gameClass.resourceName,
|
||||
lastDevice,
|
||||
liveMatch?.opponentClassName,
|
||||
log,
|
||||
maxResource,
|
||||
opponentDebuffChoicesCatalog,
|
||||
opponentLabel,
|
||||
selfBuffChoicesCatalog,
|
||||
paused,
|
||||
playerAlive,
|
||||
playerSide.buffs,
|
||||
@@ -1281,6 +1710,7 @@ export function PvPRoguelikeScreen({
|
||||
{dualScreenEnabled && status !== 'queueing' && (
|
||||
<DualScreenTopCombat
|
||||
state={dualScreenState}
|
||||
onCastSpell={castPlayerSpell}
|
||||
onSelectTarget={setSelectedTargetId}
|
||||
/>
|
||||
)}
|
||||
@@ -1292,8 +1722,15 @@ export function PvPRoguelikeScreen({
|
||||
<div>
|
||||
<p className="eyebrow">You</p>
|
||||
<h2>{profile.character.name}</h2>
|
||||
<small>{encounter.enemyName} | Stage {stage}{encounter.isBoss ? ' Boss' : ''}</small>
|
||||
</div>
|
||||
<div className="pvp-side-bars">
|
||||
<div className="pvp-clear-wrap">
|
||||
<span>Your clear {Math.max(0, Math.floor(playerSide.enemyHealth))} / {encounter.maxHealth}</span>
|
||||
<div className="bar enemy-health boss-bar">
|
||||
<span style={{ width: `${(playerSide.enemyHealth / encounter.maxHealth) * 100}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="resource-row pvp-resource-row">
|
||||
<div className="pvp-resource-wrap">
|
||||
<span>{gameClass.resourceName} {Math.floor(playerSide.resource)} / {maxResource}</span>
|
||||
{speedMultiplier === 2 && <strong className="speed-badge">2x speed</strong>}
|
||||
@@ -1340,67 +1777,20 @@ export function PvPRoguelikeScreen({
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="combat-panel pvp-middle-panel">
|
||||
<div className="encounter-header">
|
||||
<div>
|
||||
<p className="eyebrow">Encounter {encounterIndex + 1}</p>
|
||||
<h2>{encounter.enemyName}</h2>
|
||||
<small>Stage {stage}{encounter.isBoss ? ' Boss' : ''}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pvp-enemy-race">
|
||||
<div>
|
||||
<strong>Your clear</strong>
|
||||
<div className="bar enemy-health boss-bar">
|
||||
<span style={{ width: `${(playerSide.enemyHealth / encounter.maxHealth) * 100}%` }} />
|
||||
</div>
|
||||
<small>{Math.max(0, Math.floor(playerSide.enemyHealth))} / {encounter.maxHealth}</small>
|
||||
</div>
|
||||
<div>
|
||||
<strong>CPU clear</strong>
|
||||
<div className="bar enemy-health boss-bar">
|
||||
<span style={{ width: `${(cpuSide.enemyHealth / encounter.maxHealth) * 100}%` }} />
|
||||
</div>
|
||||
<small>{Math.max(0, Math.floor(cpuSide.enemyHealth))} / {encounter.maxHealth}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div className="spell-bar six-slots vertical-spell-bar pvp-vertical-spell-bar">
|
||||
{starterSpells.map((spell) => {
|
||||
const remaining = playerSide.cooldowns[spell.id] ?? 0
|
||||
const cost = spellResourceCost(spell, playerSide.buffs, playerSide.debuffs, playerSide.freeCastReady)
|
||||
return (
|
||||
<button
|
||||
className="spell"
|
||||
disabled={status !== 'playing' || playerDone || !playerAlive || remaining > 0 || playerSide.resource < cost}
|
||||
key={`middle-${spell.id}`}
|
||||
onClick={() => castPlayerSpell(spell)}
|
||||
type="button"
|
||||
>
|
||||
<kbd>
|
||||
<ControllerBindingLabel
|
||||
binding={bindings[lastDevice][`ability${Number(spell.key)}` as InputAction]}
|
||||
compact
|
||||
iconStyle={controllerIconStyle}
|
||||
/>
|
||||
</kbd>
|
||||
<span className={`spell-icon spell-${spell.kind}`}>{spell.glyph}</span>
|
||||
<strong>{spell.name}</strong>
|
||||
<small>{cost} {gameClass.resourceName}</small>
|
||||
{remaining > 0 && <i>{remaining.toFixed(1)}</i>}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<p className="roguelike-upgrade-list">CPU {cpuDifficulty} | Encounters cleared: {encountersCleared}</p>
|
||||
</section>
|
||||
|
||||
<section className="combat-panel pvp-side">
|
||||
<div className="encounter-header">
|
||||
<div>
|
||||
<p className="eyebrow">Opponent</p>
|
||||
<h2>CPU {cpuDifficulty}</h2>
|
||||
<h2>{opponentLabel}</h2>
|
||||
<small>{liveMatch ? liveMatch.opponentClassName : `CPU ${cpuDifficulty}`} | Encounters cleared: {encountersCleared}</small>
|
||||
</div>
|
||||
<div className="pvp-side-bars">
|
||||
<div className="pvp-clear-wrap">
|
||||
<span>{liveMatch ? `${liveMatch.opponentName} clear` : 'CPU clear'} {Math.max(0, Math.floor(cpuSide.enemyHealth))} / {encounter.maxHealth}</span>
|
||||
<div className="bar enemy-health boss-bar">
|
||||
<span style={{ width: `${(cpuSide.enemyHealth / encounter.maxHealth) * 100}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="resource-row pvp-resource-row">
|
||||
<div className="pvp-resource-wrap">
|
||||
<span>{gameClass.resourceName} {Math.floor(cpuSide.resource)} / {maxResource}</span>
|
||||
<div className="bar mana-bar"><span style={{ width: `${(cpuSide.resource / maxResource) * 100}%` }} /></div>
|
||||
@@ -1440,12 +1830,47 @@ export function PvPRoguelikeScreen({
|
||||
Buffs: {cpuSide.buffs.length > 0 ? summarizeStacks(cpuSide.buffs, selfBuffChoicesCatalog) : 'none'} | Debuffs: {cpuSide.debuffs.length > 0 ? summarizeStacks(cpuSide.debuffs, opponentDebuffChoicesCatalog) : 'none'}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="pvp-bottom-spell-bar" aria-label="Player abilities">
|
||||
{starterSpells.map((spell) => {
|
||||
const remaining = playerSide.cooldowns[spell.id] ?? 0
|
||||
const cost = spellResourceCost(spell, playerSide.buffs, playerSide.debuffs, playerSide.freeCastReady)
|
||||
return (
|
||||
<button
|
||||
className="spell"
|
||||
disabled={status !== 'playing' || playerDone || !playerAlive || remaining > 0 || playerSide.resource < cost}
|
||||
key={`bottom-${spell.id}`}
|
||||
onClick={() => castPlayerSpell(spell)}
|
||||
type="button"
|
||||
>
|
||||
<kbd>
|
||||
<ControllerBindingLabel
|
||||
binding={bindings[lastDevice][`ability${Number(spell.key)}` as InputAction]}
|
||||
compact
|
||||
iconStyle={controllerIconStyle}
|
||||
/>
|
||||
</kbd>
|
||||
<span className={`spell-icon spell-${spell.kind}`}>{spell.glyph}</span>
|
||||
<strong>{spell.name}</strong>
|
||||
<small>{cost} {gameClass.resourceName}</small>
|
||||
{remaining > 0 && <i>{remaining.toFixed(1)}</i>}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === 'upgrade-choice' && (
|
||||
<div className="result-screen">
|
||||
<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>
|
||||
<strong>Self Buff</strong>
|
||||
@@ -1480,8 +1905,9 @@ export function PvPRoguelikeScreen({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button className="secondary-result-button" disabled={!selectedBuff || !selectedDebuff} onClick={confirmUpgradeChoices} type="button">
|
||||
Continue
|
||||
{liveUpgradePending && <p>Waiting for opponent choice...</p>}
|
||||
<button className="secondary-result-button" disabled={!selectedBuff || !selectedDebuff || liveUpgradePending} onClick={() => confirmUpgradeChoices()} type="button">
|
||||
{liveUpgradePending ? 'Waiting' : 'Continue'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1502,7 +1928,7 @@ export function PvPRoguelikeScreen({
|
||||
<div className="result-screen">
|
||||
<div>
|
||||
<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>
|
||||
<div className="reward-summary">
|
||||
<p>{runSummary.bossesKilled} bosses killed.</p>
|
||||
@@ -1576,6 +2002,14 @@ export function PvPRoguelikeScreen({
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{liveMatch && (
|
||||
<>
|
||||
<button disabled={rematchRequested} onClick={handleRematch} type="button">
|
||||
{rematchRequested ? 'Waiting for Rematch' : 'Rematch'}
|
||||
</button>
|
||||
{rematchMessage && <p>{rematchMessage}</p>}
|
||||
</>
|
||||
)}
|
||||
<button onClick={() => startMatch()} type="button">Queue Next Match</button>
|
||||
<button className="secondary-result-button" onClick={onExit} type="button">Back to Roguelike</button>
|
||||
</div>
|
||||
|
||||
+104
-5
@@ -39,6 +39,18 @@ export type DualScreenCombatState = {
|
||||
encounterIndex: number
|
||||
encounterCount: number
|
||||
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
|
||||
selectedId: string
|
||||
log: CombatLogEntry[]
|
||||
@@ -426,17 +438,62 @@ export function DualScreenBottomDisplay() {
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="dual-bottom-display">
|
||||
<main className={`dual-bottom-display ${state.opponentParty ? 'pvp-opponent-bottom-display' : ''}`}>
|
||||
<header className="dual-controls-header">
|
||||
<div>
|
||||
<p className="eyebrow">{state.difficultyName} {state.contentName}</p>
|
||||
<h1>{state.dungeonName}</h1>
|
||||
<p className="eyebrow">{state.opponentParty ? 'Opponent View' : `${state.difficultyName} ${state.contentName}`}</p>
|
||||
<h1>{state.opponentParty ? state.opponentName : state.dungeonName}</h1>
|
||||
{state.opponentParty && <small>{state.opponentClassName}</small>}
|
||||
</div>
|
||||
<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>
|
||||
</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">
|
||||
<div>
|
||||
<p className="eyebrow">Active Target</p>
|
||||
@@ -537,6 +594,8 @@ export function DualScreenBottomDisplay() {
|
||||
)
|
||||
})}
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -544,9 +603,11 @@ export function DualScreenBottomDisplay() {
|
||||
export function DualScreenTopCombat({
|
||||
state,
|
||||
onSelectTarget,
|
||||
onCastSpell,
|
||||
}: {
|
||||
state: DualScreenCombatState
|
||||
onSelectTarget: (id: string) => void
|
||||
onCastSpell?: (spell: Spell) => void
|
||||
}) {
|
||||
const enemyPercent = Math.max(
|
||||
0,
|
||||
@@ -597,7 +658,11 @@ export function DualScreenTopCombat({
|
||||
{member.shield > 0 && (
|
||||
<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>
|
||||
{state.directPartyTargeting && targetBinding && (
|
||||
<div className="member-target-key">
|
||||
@@ -619,6 +684,40 @@ export function DualScreenTopCombat({
|
||||
</div>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
+71
-4
@@ -1,4 +1,5 @@
|
||||
import starterProfile from './offline-starter-profile.json'
|
||||
import { bundledCatalogHash } from './offline-catalog-meta'
|
||||
import type {
|
||||
Account,
|
||||
AuthSession,
|
||||
@@ -82,6 +83,12 @@ type OnlineCache = {
|
||||
dirty: boolean
|
||||
}
|
||||
|
||||
type CatalogCache = {
|
||||
version: 1
|
||||
hash: string
|
||||
profile: CharacterProfile
|
||||
}
|
||||
|
||||
export type CloudSyncStatus = {
|
||||
available: boolean
|
||||
dirty: boolean
|
||||
@@ -102,6 +109,8 @@ type LocalSaveStore = {
|
||||
const modeKey = 'chronicle.repositoryMode'
|
||||
const offlineSaveKey = 'chronicle.offlineSave.v1'
|
||||
const onlineCacheKey = 'chronicle.onlineCache.v1'
|
||||
const catalogCacheKey = 'chronicle.catalog.v1'
|
||||
const catalogBundleKey = 'chronicle.catalog.bundleHash.v1'
|
||||
const authTokenKey = 'chronicle.authToken.v1'
|
||||
const offlineAccount = { id: -1, username: 'Offline' }
|
||||
const ABILITY_SLOT_COUNT = 6
|
||||
@@ -281,8 +290,42 @@ function clearOnlineCache() {
|
||||
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 {
|
||||
const static_ = clone(starterProfile) as CharacterProfile
|
||||
const static_ = clone(activeCatalog().profile)
|
||||
const cd = save.characters[save.activeClassId]
|
||||
const gameClass = static_.classes.find((c) => c.id === save.activeClassId)!
|
||||
|
||||
@@ -628,7 +671,7 @@ function getApiBaseUrl(path: string): string {
|
||||
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 url = baseUrl ? `${baseUrl}${path}` : path
|
||||
const headers = new Headers(init?.headers)
|
||||
@@ -653,10 +696,31 @@ async function requestJson<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
return body
|
||||
}
|
||||
|
||||
async function requestJson<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
return requestGameApiJson(path, init)
|
||||
}
|
||||
|
||||
function isNetworkError(reason: unknown): reason is NetworkError {
|
||||
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 {
|
||||
const cache = readOnlineCache()
|
||||
if (!cache) return null
|
||||
@@ -698,6 +762,7 @@ async function pushServerSyncSave(save: OfflineSave): Promise<{ profile: Charact
|
||||
async function finalizeOnlineSession(session: AuthSession): Promise<AuthSession> {
|
||||
const cache = readOnlineCache()
|
||||
if (session.token) writeAuthToken(session.token)
|
||||
await refreshCatalogFromServer()
|
||||
if (!session.account || !session.profile) {
|
||||
if (session.account && cache?.account.id === session.account.id) {
|
||||
return {
|
||||
@@ -848,7 +913,7 @@ const serverRepository: GameRepository = {
|
||||
}
|
||||
|
||||
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 talentRanks: Record<string, number> = {}
|
||||
for (const t of gc.talents) talentRanks[String(t.id)] = 0
|
||||
@@ -1544,7 +1609,9 @@ export async function syncCloudSave(): Promise<CharacterProfile> {
|
||||
if (!cache) {
|
||||
throw new Error('No signed-in save is available for cloud sync.')
|
||||
}
|
||||
await refreshCatalogFromServer()
|
||||
const synced = await pushServerSyncSave(cache.save)
|
||||
await refreshCatalogFromServer()
|
||||
writeOnlineCache({
|
||||
version: 1,
|
||||
account: cache.account,
|
||||
@@ -1552,7 +1619,7 @@ export async function syncCloudSave(): Promise<CharacterProfile> {
|
||||
dirty: false,
|
||||
})
|
||||
writeMode('online')
|
||||
return synced.profile
|
||||
return buildProfile(synced.save)
|
||||
}
|
||||
|
||||
export function selectOnlineMode() {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const bundledCatalogHash = '07506c52bab428c439f09a9b82e39e6eff2b243fb972d664da48852059bcd937'
|
||||
@@ -1437,6 +1437,22 @@
|
||||
"setName": null
|
||||
},
|
||||
"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": {
|
||||
"id": 683002,
|
||||
@@ -1450,7 +1466,7 @@
|
||||
"glyph": "$",
|
||||
"description": "A boss coin from Tigrex used for item level 10 crafting."
|
||||
},
|
||||
"quantity": 10,
|
||||
"quantity": 5,
|
||||
"owned": 0
|
||||
}
|
||||
],
|
||||
@@ -1477,6 +1493,22 @@
|
||||
"setName": null
|
||||
},
|
||||
"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": {
|
||||
"id": 683002,
|
||||
@@ -1490,7 +1522,7 @@
|
||||
"glyph": "$",
|
||||
"description": "A boss coin from Tigrex used for item level 10 crafting."
|
||||
},
|
||||
"quantity": 10,
|
||||
"quantity": 5,
|
||||
"owned": 0
|
||||
}
|
||||
],
|
||||
@@ -1517,6 +1549,22 @@
|
||||
"setName": null
|
||||
},
|
||||
"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": {
|
||||
"id": 683002,
|
||||
@@ -1530,7 +1578,7 @@
|
||||
"glyph": "$",
|
||||
"description": "A boss coin from Tigrex used for item level 10 crafting."
|
||||
},
|
||||
"quantity": 10,
|
||||
"quantity": 5,
|
||||
"owned": 0
|
||||
}
|
||||
],
|
||||
|
||||
@@ -1,5 +1,56 @@
|
||||
import { requestGameApiJson } from './gameRepository'
|
||||
|
||||
export type PvpContentType = 'dungeon' | 'raid'
|
||||
export type CpuDifficulty = 1 | 2 | 3 | 4 | 5
|
||||
export type PvpMatchSide = 'a' | 'b'
|
||||
|
||||
export type PvpPlayerInfo = {
|
||||
side: PvpMatchSide
|
||||
accountId: number
|
||||
characterId: number
|
||||
characterName: string
|
||||
className: string
|
||||
}
|
||||
|
||||
export type PvpUpgradeChoicePayload = {
|
||||
encounterIndex: number
|
||||
buffId: string
|
||||
debuffId: string
|
||||
}
|
||||
|
||||
export type PvpMatchSnapshot<TSideState = unknown> = {
|
||||
id: string
|
||||
contentType: PvpContentType
|
||||
startStage: number
|
||||
createdAt: number
|
||||
players: Record<PvpMatchSide, PvpPlayerInfo>
|
||||
states: Partial<Record<PvpMatchSide, TSideState>>
|
||||
statuses: Partial<Record<PvpMatchSide, 'playing' | 'upgrade-choice' | 'won' | 'lost'>>
|
||||
progress: Partial<Record<PvpMatchSide, {
|
||||
stage: number
|
||||
encounterIndex: number
|
||||
encountersCleared: number
|
||||
enemyHealth: number
|
||||
alive: boolean
|
||||
elapsedTicks: number
|
||||
}>>
|
||||
upgradeChoices: Partial<Record<PvpMatchSide, Record<string, PvpUpgradeChoicePayload>>>
|
||||
rematchRequests?: Partial<Record<PvpMatchSide, boolean>>
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
export type PvpQueueResponse<TSideState = unknown> = {
|
||||
ticketId: string
|
||||
status: 'waiting' | 'matched'
|
||||
match?: PvpMatchSnapshot<TSideState>
|
||||
side?: PvpMatchSide
|
||||
}
|
||||
|
||||
export type PvpRematchResponse<TSideState = unknown> = {
|
||||
status: 'waiting' | 'matched'
|
||||
match?: PvpMatchSnapshot<TSideState>
|
||||
side?: PvpMatchSide
|
||||
}
|
||||
|
||||
export type CpuPvpLeaderboardEntry = {
|
||||
characterName: string
|
||||
@@ -66,3 +117,65 @@ export function recordPvpRoguelikeCheckpoint(
|
||||
localStorage.setItem(checkpointStorageKey(characterId, contentType), String(next))
|
||||
return next
|
||||
}
|
||||
|
||||
export function joinPvpQueue<TSideState>(
|
||||
contentType: PvpContentType,
|
||||
startStage: number,
|
||||
): Promise<PvpQueueResponse<TSideState>> {
|
||||
return requestGameApiJson('/api/pvp/queue', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ contentType, startStage }),
|
||||
})
|
||||
}
|
||||
|
||||
export function checkPvpQueue<TSideState>(ticketId: string): Promise<PvpQueueResponse<TSideState>> {
|
||||
return requestGameApiJson(`/api/pvp/queue/${encodeURIComponent(ticketId)}`)
|
||||
}
|
||||
|
||||
export function cancelPvpQueue(ticketId: string): Promise<{ ok: true }> {
|
||||
return requestGameApiJson(`/api/pvp/queue/${encodeURIComponent(ticketId)}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
export function publishPvpMatchState<TSideState>(
|
||||
matchId: string,
|
||||
payload: {
|
||||
state: TSideState
|
||||
status: 'playing' | 'upgrade-choice' | 'won' | 'lost'
|
||||
stage: number
|
||||
encounterIndex: number
|
||||
encountersCleared: number
|
||||
enemyHealth: number
|
||||
alive: boolean
|
||||
elapsedTicks: number
|
||||
},
|
||||
): Promise<PvpMatchSnapshot<TSideState>> {
|
||||
return requestGameApiJson(`/api/pvp/matches/${encodeURIComponent(matchId)}/state`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function loadPvpMatch<TSideState>(matchId: string): Promise<PvpMatchSnapshot<TSideState>> {
|
||||
return requestGameApiJson(`/api/pvp/matches/${encodeURIComponent(matchId)}`)
|
||||
}
|
||||
|
||||
export function submitPvpUpgradeChoice(
|
||||
matchId: string,
|
||||
payload: PvpUpgradeChoicePayload,
|
||||
): Promise<PvpMatchSnapshot> {
|
||||
return requestGameApiJson(`/api/pvp/matches/${encodeURIComponent(matchId)}/upgrade-choice`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function requestPvpRematch<TSideState>(matchId: string): Promise<PvpRematchResponse<TSideState>> {
|
||||
return requestGameApiJson(`/api/pvp/matches/${encodeURIComponent(matchId)}/rematch`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user