Compare commits

..

5 Commits

Author SHA1 Message Date
Warren H c0f2daccb1 Android build v1.0.57 2026-06-21 21:09:51 -04:00
Warren H abdf4cc654 Android build v1.0.56 2026-06-21 21:00:17 -04:00
Warren H 5449276521 Android build v1.0.55 2026-06-21 20:38:55 -04:00
Warren H 787e2bbae9 Android build v1.0.54 2026-06-21 20:22:12 -04:00
Warren H 421540c52b Android build v1.0.53 2026-06-21 20:07:26 -04:00
19 changed files with 1829 additions and 270 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+2 -2
View File
@@ -7,8 +7,8 @@ android {
applicationId "com.warren.iwanttoheal"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 71
versionName "1.0.52"
versionCode 76
versionName "1.0.57"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
+59
View File
@@ -0,0 +1,59 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1440" height="900" viewBox="0 0 1440 900">
<defs>
<linearGradient id="bg" x1="0" x2="1" y1="0" y2="1"><stop offset="0" stop-color="#111827"/><stop offset="1" stop-color="#2f1f16"/></linearGradient>
<style>
text{font-family:Inter,Arial,sans-serif;fill:#f8f1df}.muted{fill:#c8bca6;font-size:20px}.tiny{fill:#d8cab1;font-size:16px}.title{font-size:30px;font-weight:800}.label{font-size:19px;font-weight:700}.panel{fill:#1b2433;stroke:#6f5535;stroke-width:2;rx:12}.tile{fill:#253244;stroke:#7d6743;stroke-width:2;rx:10}.selected{fill:#31483b;stroke:#d7aa45;stroke-width:4;rx:10}.bar-bg{fill:#2a3444;rx:8}.hp{fill:#5ed17a;rx:8}.danger{fill:#d96b55;rx:8}.mana{fill:#63a9ff;rx:8}.button{fill:#d7aa45;rx:10}.spell{fill:#273449;stroke:#8d7349;stroke-width:2;rx:10}
</style>
</defs>
<rect width="1440" height="900" fill="url(#bg)"/>
<text x="48" y="64" class="title">PC Dungeon PvP Roguelike</text>
<text x="48" y="96" class="muted">Dungeon party = 6 members per side. Upgrade timer auto-picks at 0s.</text>
<rect x="36" y="132" width="402" height="704" class="panel"/>
<text x="64" y="176" class="title">You</text><text x="64" y="204" class="muted">Mira - Priest</text>
<text x="64" y="252" class="label">Party Health</text>
<g transform="translate(64 278)">
<rect width="150" height="82" class="selected"/><text x="16" y="31" class="label">Tank</text><text x="106" y="31" class="tiny">82%</text><rect x="16" y="52" width="118" height="12" class="bar-bg"/><rect x="16" y="52" width="97" height="12" class="hp"/>
<rect x="170" width="150" height="82" class="tile"/><text x="186" y="31" class="label">Mira</text><text x="276" y="31" class="tiny">74%</text><rect x="186" y="52" width="118" height="12" class="bar-bg"/><rect x="186" y="52" width="87" height="12" class="hp"/>
<rect y="104" width="150" height="82" class="tile"/><text x="16" y="135" class="label">DPS</text><text x="106" y="135" class="tiny">55%</text><rect x="16" y="156" width="118" height="12" class="bar-bg"/><rect x="16" y="156" width="65" height="12" class="danger"/>
<rect x="170" y="104" width="150" height="82" class="tile"/><text x="186" y="135" class="label">DPS</text><text x="276" y="135" class="tiny">92%</text><rect x="186" y="156" width="118" height="12" class="bar-bg"/><rect x="186" y="156" width="109" height="12" class="hp"/>
<rect y="208" width="150" height="82" class="tile"/><text x="16" y="239" class="label">DPS</text><text x="106" y="239" class="tiny">66%</text><rect x="16" y="260" width="118" height="12" class="bar-bg"/><rect x="16" y="260" width="78" height="12" class="hp"/>
<rect x="170" y="208" width="150" height="82" class="tile"/><text x="186" y="239" class="label">DPS</text><text x="276" y="239" class="tiny">41%</text><rect x="186" y="260" width="118" height="12" class="bar-bg"/><rect x="186" y="260" width="48" height="12" class="danger"/>
</g>
<text x="64" y="626" class="tiny">Buffs: Wide Radiance x1</text><text x="64" y="654" class="tiny">Debuffs: Mana Squeeze x1</text>
<rect x="472" y="132" width="496" height="704" class="panel"/>
<text x="504" y="176" class="title">Stage 15 Boss</text><text x="504" y="204" class="muted">Bulldrome Guardian</text>
<text x="504" y="260" class="label">Your Clear</text><rect x="504" y="278" width="400" height="24" class="bar-bg"/><rect x="504" y="278" width="176" height="24" class="danger"/>
<text x="504" y="342" class="label">Astra Clear</text><rect x="504" y="360" width="400" height="24" class="bar-bg"/><rect x="504" y="360" width="246" height="24" class="danger"/>
<g transform="translate(520 436)">
<rect width="400" height="260" fill="#161f2c" stroke="#d7aa45" stroke-width="3" rx="14"/>
<text x="32" y="44" class="title">Choose Edge</text><text x="274" y="44" class="title">07.4s</text>
<text x="32" y="88" class="label">Self Buff</text><text x="210" y="88" class="label">Opponent Debuff</text>
<rect x="32" y="112" width="150" height="52" class="selected"/><text x="46" y="145" class="tiny">+1 Target</text>
<rect x="32" y="178" width="150" height="52" class="tile"/><text x="46" y="211" class="tiny">-25% Cost</text>
<rect x="210" y="112" width="150" height="52" class="tile"/><text x="224" y="145" class="tiny">Cost Up</text>
<rect x="210" y="178" width="150" height="52" class="selected"/><text x="224" y="211" class="tiny">Mana Squeeze</text>
</g>
<g transform="translate(520 728)">
<rect width="56" height="52" class="spell"/><text x="22" y="34" font-size="24" font-weight="800">+</text>
<rect x="68" width="56" height="52" class="spell"/><text x="88" y="34" font-size="24" font-weight="800">R</text>
<rect x="136" width="56" height="52" class="spell"/><text x="156" y="34" font-size="24" font-weight="800">S</text>
<rect x="204" width="56" height="52" class="spell"/><text x="224" y="34" font-size="24" font-weight="800">G</text>
<rect x="272" width="56" height="52" class="spell"/><text x="292" y="34" font-size="24" font-weight="800">C</text>
<rect x="340" width="56" height="52" class="spell"/><text x="360" y="34" font-size="24" font-weight="800">B</text>
</g>
<rect x="1002" y="132" width="402" height="704" class="panel"/>
<text x="1030" y="176" class="title">Opponent</text><text x="1030" y="204" class="muted">Astra - Druid</text>
<text x="1030" y="252" class="label">Opponent Party</text>
<g transform="translate(1030 278)">
<rect width="150" height="82" class="tile"/><text x="16" y="31" class="label">Tank</text><text x="106" y="31" class="tiny">59%</text><rect x="16" y="52" width="118" height="12" class="bar-bg"/><rect x="16" y="52" width="70" height="12" class="danger"/>
<rect x="170" width="150" height="82" class="tile"/><text x="186" y="31" class="label">Astra</text><text x="276" y="31" class="tiny">88%</text><rect x="186" y="52" width="118" height="12" class="bar-bg"/><rect x="186" y="52" width="104" height="12" class="hp"/>
<rect y="104" width="150" height="82" class="tile"/><text x="16" y="135" class="label">DPS</text><text x="106" y="135" class="tiny">73%</text><rect x="16" y="156" width="118" height="12" class="bar-bg"/><rect x="16" y="156" width="86" height="12" class="hp"/>
<rect x="170" y="104" width="150" height="82" class="tile"/><text x="186" y="135" class="label">DPS</text><text x="276" y="135" class="tiny">42%</text><rect x="186" y="156" width="118" height="12" class="bar-bg"/><rect x="186" y="156" width="50" height="12" class="danger"/>
<rect y="208" width="150" height="82" class="tile"/><text x="16" y="239" class="label">DPS</text><text x="106" y="239" class="tiny">91%</text><rect x="16" y="260" width="118" height="12" class="bar-bg"/><rect x="16" y="260" width="107" height="12" class="hp"/>
<rect x="170" y="208" width="150" height="82" class="tile"/><text x="186" y="239" class="label">DPS</text><text x="276" y="239" class="tiny">66%</text><rect x="186" y="260" width="118" height="12" class="bar-bg"/><rect x="186" y="260" width="78" height="12" class="hp"/>
</g>
<text x="1030" y="626" class="tiny">Buffs: Dense Shields x1</text><text x="1030" y="654" class="tiny">Debuffs: Cost Up x1</text>
</svg>

After

Width:  |  Height:  |  Size: 7.1 KiB

+30
View File
@@ -0,0 +1,30 @@
<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540" viewBox="0 0 960 540">
<defs>
<linearGradient id="bg" x1="0" x2="1" y1="0" y2="1"><stop offset="0" stop-color="#101827"/><stop offset="1" stop-color="#271a12"/></linearGradient>
<style>
text{font-family:Inter,Arial,sans-serif;fill:#f8f1df}.muted{fill:#c8bca6;font-size:15px}.title{font-size:24px;font-weight:800}.label{font-size:16px;font-weight:700}.panel{fill:#1b2433;stroke:#6f5535;stroke-width:2;rx:10}.tile{fill:#253244;stroke:#7d6743;stroke-width:2;rx:8}.selected{fill:#31483b;stroke:#d7aa45;stroke-width:4;rx:8}.bar-bg{fill:#2a3444;rx:7}.hp{fill:#5ed17a;rx:7}.danger{fill:#d96b55;rx:7}.mana{fill:#63a9ff;rx:7}.spell{fill:#273449;stroke:#8d7349;stroke-width:2;rx:10}.ready{fill:#31483b;stroke:#d7aa45;stroke-width:3;rx:10}
</style>
</defs>
<rect width="960" height="540" fill="url(#bg)"/>
<text x="24" y="38" class="title">Thor Main - Dungeon PvP</text>
<text x="24" y="62" class="muted">Dungeon party shows exactly 6 members. Opponent health stays on bottom screen.</text>
<rect x="24" y="86" width="912" height="350" class="panel"/>
<text x="52" y="124" class="title">Mira Party</text><text x="760" y="124" class="label">Stage 15 Boss</text>
<rect x="760" y="140" width="132" height="16" class="bar-bg"/><rect x="760" y="140" width="58" height="16" class="danger"/>
<g transform="translate(52 164)">
<rect width="132" height="96" class="selected"/><text x="16" y="28" class="label">Tank</text><text x="16" y="52" class="muted">82 / 100</text><rect x="16" y="68" width="100" height="12" class="bar-bg"/><rect x="16" y="68" width="82" height="12" class="hp"/>
<rect x="150" width="132" height="96" class="tile"/><text x="166" y="28" class="label">Mira</text><text x="166" y="52" class="muted">74 / 100</text><rect x="166" y="68" width="100" height="12" class="bar-bg"/><rect x="166" y="68" width="74" height="12" class="hp"/>
<rect x="300" width="132" height="96" class="tile"/><text x="316" y="28" class="label">DPS</text><text x="316" y="52" class="muted">55 / 100</text><rect x="316" y="68" width="100" height="12" class="bar-bg"/><rect x="316" y="68" width="55" height="12" class="danger"/>
<rect y="116" width="132" height="96" class="tile"/><text x="16" y="144" class="label">DPS</text><text x="16" y="168" class="muted">92 / 100</text><rect x="16" y="184" width="100" height="12" class="bar-bg"/><rect x="16" y="184" width="92" height="12" class="hp"/>
<rect x="150" y="116" width="132" height="96" class="tile"/><text x="166" y="144" class="label">DPS</text><text x="166" y="168" class="muted">66 / 100</text><rect x="166" y="184" width="100" height="12" class="bar-bg"/><rect x="166" y="184" width="66" height="12" class="hp"/>
<rect x="300" y="116" width="132" height="96" class="tile"/><text x="316" y="144" class="label">DPS</text><text x="316" y="168" class="muted">41 / 100</text><rect x="316" y="184" width="100" height="12" class="bar-bg"/><rect x="316" y="184" width="41" height="12" class="danger"/>
</g>
<rect x="24" y="452" width="912" height="68" class="panel"/>
<rect x="48" y="468" width="52" height="40" class="ready"/><text x="66" y="496" font-size="22" font-weight="800">+</text>
<rect x="112" y="468" width="52" height="40" class="spell"/><text x="129" y="496" font-size="22" font-weight="800">R</text><text x="143" y="483" font-size="12">3</text>
<rect x="176" y="468" width="52" height="40" class="spell"/><text x="194" y="496" font-size="22" font-weight="800">S</text><text x="207" y="483" font-size="12">8</text>
<rect x="240" y="468" width="52" height="40" class="ready"/><text x="258" y="496" font-size="22" font-weight="800">G</text>
<rect x="304" y="468" width="52" height="40" class="spell"/><text x="322" y="496" font-size="22" font-weight="800">C</text><text x="335" y="483" font-size="12">5</text>
<rect x="368" y="468" width="52" height="40" class="ready"/><text x="386" y="496" font-size="22" font-weight="800">B</text>
<text x="650" y="480" class="label">Mana 73 / 100</text><rect x="650" y="492" width="240" height="14" class="bar-bg"/><rect x="650" y="492" width="175" height="14" class="mana"/>
</svg>

After

Width:  |  Height:  |  Size: 4.1 KiB

+62
View File
@@ -0,0 +1,62 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1440" height="900" viewBox="0 0 1440 900">
<defs>
<linearGradient id="bg" x1="0" x2="1" y1="0" y2="1"><stop offset="0" stop-color="#111827"/><stop offset="1" stop-color="#2f1f16"/></linearGradient>
<style>
text{font-family:Inter,Arial,sans-serif;fill:#f8f1df}.muted{fill:#c8bca6;font-size:20px}.tiny{fill:#d8cab1;font-size:14px}.title{font-size:30px;font-weight:800}.label{font-size:18px;font-weight:700}.panel{fill:#1b2433;stroke:#6f5535;stroke-width:2;rx:12}.tile{fill:#253244;stroke:#7d6743;stroke-width:2;rx:8}.bar-bg{fill:#2a3444;rx:7}.hp{fill:#5ed17a;rx:7}.danger{fill:#d96b55;rx:7}.spell{fill:#273449;stroke:#8d7349;stroke-width:2;rx:10}.selected{fill:#31483b;stroke:#d7aa45;stroke-width:3;rx:8}
</style>
</defs>
<rect width="1440" height="900" fill="url(#bg)"/>
<text x="48" y="64" class="title">PC Raid PvP Roguelike</text>
<text x="48" y="96" class="muted">Raid party = compact roster. Opponent panel mirrors raid health without crowding center.</text>
<rect x="36" y="132" width="430" height="704" class="panel"/>
<text x="64" y="176" class="title">You</text><text x="64" y="204" class="muted">Mira Raid Group</text>
<g transform="translate(64 238)">
<rect width="104" height="56" class="selected"/><text x="10" y="24" class="tiny">Tank 82%</text><rect x="10" y="36" width="84" height="8" class="bar-bg"/><rect x="10" y="36" width="69" height="8" class="hp"/>
<rect x="116" width="104" height="56" class="tile"/><text x="126" y="24" class="tiny">Mira 74%</text><rect x="126" y="36" width="84" height="8" class="bar-bg"/><rect x="126" y="36" width="62" height="8" class="hp"/>
<rect x="232" width="104" height="56" class="tile"/><text x="242" y="24" class="tiny">DPS 55%</text><rect x="242" y="36" width="84" height="8" class="bar-bg"/><rect x="242" y="36" width="46" height="8" class="danger"/>
<rect y="72" width="104" height="56" class="tile"/><text x="10" y="96" class="tiny">DPS 92%</text><rect x="10" y="108" width="84" height="8" class="bar-bg"/><rect x="10" y="108" width="77" height="8" class="hp"/>
<rect x="116" y="72" width="104" height="56" class="tile"/><text x="126" y="96" class="tiny">DPS 66%</text><rect x="126" y="108" width="84" height="8" class="bar-bg"/><rect x="126" y="108" width="55" height="8" class="hp"/>
<rect x="232" y="72" width="104" height="56" class="tile"/><text x="242" y="96" class="tiny">DPS 41%</text><rect x="242" y="108" width="84" height="8" class="bar-bg"/><rect x="242" y="108" width="34" height="8" class="danger"/>
<rect y="144" width="104" height="56" class="tile"/><text x="10" y="168" class="tiny">DPS 88%</text><rect x="10" y="180" width="84" height="8" class="bar-bg"/><rect x="10" y="180" width="74" height="8" class="hp"/>
<rect x="116" y="144" width="104" height="56" class="tile"/><text x="126" y="168" class="tiny">DPS 63%</text><rect x="126" y="180" width="84" height="8" class="bar-bg"/><rect x="126" y="180" width="53" height="8" class="hp"/>
<rect x="232" y="144" width="104" height="56" class="tile"/><text x="242" y="168" class="tiny">DPS 77%</text><rect x="242" y="180" width="84" height="8" class="bar-bg"/><rect x="242" y="180" width="65" height="8" class="hp"/>
<rect y="216" width="104" height="56" class="tile"/><text x="10" y="240" class="tiny">DPS 49%</text><rect x="10" y="252" width="84" height="8" class="bar-bg"/><rect x="10" y="252" width="41" height="8" class="danger"/>
<rect x="116" y="216" width="104" height="56" class="tile"/><text x="126" y="240" class="tiny">DPS 96%</text><rect x="126" y="252" width="84" height="8" class="bar-bg"/><rect x="126" y="252" width="81" height="8" class="hp"/>
<rect x="232" y="216" width="104" height="56" class="tile"/><text x="242" y="240" class="tiny">DPS 70%</text><rect x="242" y="252" width="84" height="8" class="bar-bg"/><rect x="242" y="252" width="59" height="8" class="hp"/>
</g>
<text x="64" y="590" class="tiny">Direct target group: 1 / 2</text><text x="64" y="620" class="tiny">Buffs: Wide Radiance x1</text>
<rect x="502" y="132" width="436" height="704" class="panel"/>
<text x="534" y="176" class="title">Raid Stage 20 Boss</text><text x="534" y="204" class="muted">Ashen Warden</text>
<text x="534" y="260" class="label">Your Clear</text><rect x="534" y="278" width="340" height="24" class="bar-bg"/><rect x="534" y="278" width="176" height="24" class="danger"/>
<text x="534" y="342" class="label">Astra Clear</text><rect x="534" y="360" width="340" height="24" class="bar-bg"/><rect x="534" y="360" width="218" height="24" class="danger"/>
<rect x="534" y="438" width="340" height="220" fill="#161f2c" stroke="#d7aa45" stroke-width="3" rx="14"/>
<text x="558" y="482" class="title">Choose Edge</text><text x="766" y="482" class="title">10.0s</text>
<rect x="558" y="520" width="132" height="48" class="selected"/><text x="572" y="551" class="tiny">Raid Heal +</text>
<rect x="708" y="520" width="132" height="48" class="tile"/><text x="722" y="551" class="tiny">Mana Squeeze</text>
<rect x="558" y="584" width="132" height="48" class="tile"/><text x="572" y="615" class="tiny">Shield Boost</text>
<rect x="708" y="584" width="132" height="48" class="selected"/><text x="722" y="615" class="tiny">Cooldown Up</text>
<g transform="translate(534 724)">
<rect width="48" height="48" class="spell"/><text x="18" y="32" font-size="22" font-weight="800">+</text>
<rect x="60" width="48" height="48" class="spell"/><text x="77" y="32" font-size="22" font-weight="800">R</text>
<rect x="120" width="48" height="48" class="spell"/><text x="137" y="32" font-size="22" font-weight="800">S</text>
<rect x="180" width="48" height="48" class="spell"/><text x="197" y="32" font-size="22" font-weight="800">G</text>
<rect x="240" width="48" height="48" class="spell"/><text x="257" y="32" font-size="22" font-weight="800">C</text>
<rect x="300" width="48" height="48" class="spell"/><text x="317" y="32" font-size="22" font-weight="800">B</text>
</g>
<rect x="974" y="132" width="430" height="704" class="panel"/>
<text x="1002" y="176" class="title">Opponent Raid</text><text x="1002" y="204" class="muted">Astra Group</text>
<g transform="translate(1002 238)">
<rect width="104" height="56" class="tile"/><text x="10" y="24" class="tiny">Tank 61%</text><rect x="10" y="36" width="84" height="8" class="bar-bg"/><rect x="10" y="36" width="51" height="8" class="danger"/>
<rect x="116" width="104" height="56" class="tile"/><text x="126" y="24" class="tiny">Astra 89%</text><rect x="126" y="36" width="84" height="8" class="bar-bg"/><rect x="126" y="36" width="75" height="8" class="hp"/>
<rect x="232" width="104" height="56" class="tile"/><text x="242" y="24" class="tiny">DPS 73%</text><rect x="242" y="36" width="84" height="8" class="bar-bg"/><rect x="242" y="36" width="61" height="8" class="hp"/>
<rect y="72" width="104" height="56" class="tile"/><text x="10" y="96" class="tiny">DPS 42%</text><rect x="10" y="108" width="84" height="8" class="bar-bg"/><rect x="10" y="108" width="35" height="8" class="danger"/>
<rect x="116" y="72" width="104" height="56" class="tile"/><text x="126" y="96" class="tiny">DPS 91%</text><rect x="126" y="108" width="84" height="8" class="bar-bg"/><rect x="126" y="108" width="76" height="8" class="hp"/>
<rect x="232" y="72" width="104" height="56" class="tile"/><text x="242" y="96" class="tiny">DPS 66%</text><rect x="242" y="108" width="84" height="8" class="bar-bg"/><rect x="242" y="108" width="55" height="8" class="hp"/>
<rect y="144" width="104" height="56" class="tile"/><text x="10" y="168" class="tiny">DPS 79%</text><rect x="10" y="180" width="84" height="8" class="bar-bg"/><rect x="10" y="180" width="66" height="8" class="hp"/>
<rect x="116" y="144" width="104" height="56" class="tile"/><text x="126" y="168" class="tiny">DPS 58%</text><rect x="126" y="180" width="84" height="8" class="bar-bg"/><rect x="126" y="180" width="49" height="8" class="danger"/>
<rect x="232" y="144" width="104" height="56" class="tile"/><text x="242" y="168" class="tiny">DPS 84%</text><rect x="242" y="180" width="84" height="8" class="bar-bg"/><rect x="242" y="180" width="71" height="8" class="hp"/>
</g>
<text x="1002" y="590" class="tiny">Opponent buffs/debuffs visible here</text><text x="1002" y="620" class="tiny">Raid bottom screen can scroll second group if needed</text>
</svg>

After

Width:  |  Height:  |  Size: 8.3 KiB

+36
View File
@@ -0,0 +1,36 @@
<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540" viewBox="0 0 960 540">
<defs>
<linearGradient id="bg" x1="0" x2="1" y1="0" y2="1"><stop offset="0" stop-color="#101827"/><stop offset="1" stop-color="#271a12"/></linearGradient>
<style>
text{font-family:Inter,Arial,sans-serif;fill:#f8f1df}.muted{fill:#c8bca6;font-size:15px}.title{font-size:24px;font-weight:800}.label{font-size:15px;font-weight:700}.panel{fill:#1b2433;stroke:#6f5535;stroke-width:2;rx:10}.tile{fill:#253244;stroke:#7d6743;stroke-width:2;rx:8}.selected{fill:#31483b;stroke:#d7aa45;stroke-width:4;rx:8}.bar-bg{fill:#2a3444;rx:7}.hp{fill:#5ed17a;rx:7}.danger{fill:#d96b55;rx:7}.mana{fill:#63a9ff;rx:7}.spell{fill:#273449;stroke:#8d7349;stroke-width:2;rx:10}.ready{fill:#31483b;stroke:#d7aa45;stroke-width:3;rx:10}
</style>
</defs>
<rect width="960" height="540" fill="url(#bg)"/>
<text x="24" y="38" class="title">Thor Main - Raid PvP</text>
<text x="24" y="62" class="muted">Raid party uses compact tiles plus target group toggle. Bottom screen mirrors opponent raid.</text>
<rect x="24" y="86" width="912" height="350" class="panel"/>
<text x="52" y="122" class="title">Mira Raid</text><text x="214" y="122" class="muted">Target Group 1 / 2</text>
<text x="760" y="122" class="label">Raid Boss</text><rect x="760" y="138" width="132" height="16" class="bar-bg"/><rect x="760" y="138" width="72" height="16" class="danger"/>
<g transform="translate(52 156)">
<rect width="104" height="72" class="selected"/><text x="12" y="28" class="label">Tank</text><text x="62" y="28" class="muted">82%</text><rect x="12" y="46" width="80" height="10" class="bar-bg"/><rect x="12" y="46" width="66" height="10" class="hp"/>
<rect x="118" width="104" height="72" class="tile"/><text x="130" y="28" class="label">Mira</text><text x="180" y="28" class="muted">74%</text><rect x="130" y="46" width="80" height="10" class="bar-bg"/><rect x="130" y="46" width="59" height="10" class="hp"/>
<rect x="236" width="104" height="72" class="tile"/><text x="248" y="28" class="label">DPS</text><text x="298" y="28" class="muted">55%</text><rect x="248" y="46" width="80" height="10" class="bar-bg"/><rect x="248" y="46" width="44" height="10" class="danger"/>
<rect x="354" width="104" height="72" class="tile"/><text x="366" y="28" class="label">DPS</text><text x="416" y="28" class="muted">92%</text><rect x="366" y="46" width="80" height="10" class="bar-bg"/><rect x="366" y="46" width="74" height="10" class="hp"/>
<rect x="472" width="104" height="72" class="tile"/><text x="484" y="28" class="label">DPS</text><text x="534" y="28" class="muted">66%</text><rect x="484" y="46" width="80" height="10" class="bar-bg"/><rect x="484" y="46" width="53" height="10" class="hp"/>
<rect x="590" width="104" height="72" class="tile"/><text x="602" y="28" class="label">DPS</text><text x="652" y="28" class="muted">41%</text><rect x="602" y="46" width="80" height="10" class="bar-bg"/><rect x="602" y="46" width="33" height="10" class="danger"/>
<rect y="92" width="104" height="72" class="tile"/><text x="12" y="120" class="label">DPS</text><text x="62" y="120" class="muted">88%</text><rect x="12" y="138" width="80" height="10" class="bar-bg"/><rect x="12" y="138" width="70" height="10" class="hp"/>
<rect x="118" y="92" width="104" height="72" class="tile"/><text x="130" y="120" class="label">DPS</text><text x="180" y="120" class="muted">63%</text><rect x="130" y="138" width="80" height="10" class="bar-bg"/><rect x="130" y="138" width="50" height="10" class="hp"/>
<rect x="236" y="92" width="104" height="72" class="tile"/><text x="248" y="120" class="label">DPS</text><text x="298" y="120" class="muted">77%</text><rect x="248" y="138" width="80" height="10" class="bar-bg"/><rect x="248" y="138" width="62" height="10" class="hp"/>
<rect x="354" y="92" width="104" height="72" class="tile"/><text x="366" y="120" class="label">DPS</text><text x="416" y="120" class="muted">49%</text><rect x="366" y="138" width="80" height="10" class="bar-bg"/><rect x="366" y="138" width="39" height="10" class="danger"/>
<rect x="472" y="92" width="104" height="72" class="tile"/><text x="484" y="120" class="label">DPS</text><text x="534" y="120" class="muted">96%</text><rect x="484" y="138" width="80" height="10" class="bar-bg"/><rect x="484" y="138" width="77" height="10" class="hp"/>
<rect x="590" y="92" width="104" height="72" class="tile"/><text x="602" y="120" class="label">DPS</text><text x="652" y="120" class="muted">70%</text><rect x="602" y="138" width="80" height="10" class="bar-bg"/><rect x="602" y="138" width="56" height="10" class="hp"/>
</g>
<rect x="24" y="452" width="912" height="68" class="panel"/>
<rect x="48" y="468" width="52" height="40" class="ready"/><text x="66" y="496" font-size="22" font-weight="800">+</text>
<rect x="112" y="468" width="52" height="40" class="spell"/><text x="129" y="496" font-size="22" font-weight="800">R</text><text x="143" y="483" font-size="12">3</text>
<rect x="176" y="468" width="52" height="40" class="spell"/><text x="194" y="496" font-size="22" font-weight="800">S</text><text x="207" y="483" font-size="12">8</text>
<rect x="240" y="468" width="52" height="40" class="ready"/><text x="258" y="496" font-size="22" font-weight="800">G</text>
<rect x="304" y="468" width="52" height="40" class="spell"/><text x="322" y="496" font-size="22" font-weight="800">C</text><text x="335" y="483" font-size="12">5</text>
<rect x="368" y="468" width="52" height="40" class="ready"/><text x="386" y="496" font-size="22" font-weight="800">B</text>
<text x="650" y="480" class="label">Mana 73 / 100</text><rect x="650" y="492" width="240" height="14" class="bar-bg"/><rect x="650" y="492" width="175" height="14" class="mana"/>
</svg>

After

Width:  |  Height:  |  Size: 5.7 KiB

+41
View File
@@ -0,0 +1,41 @@
<svg xmlns="http://www.w3.org/2000/svg" width="620" height="540" viewBox="0 0 620 540">
<defs>
<linearGradient id="bg" x1="0" x2="1" y1="0" y2="1">
<stop offset="0" stop-color="#101827"/>
<stop offset="1" stop-color="#271a12"/>
</linearGradient>
<style>
text { font-family: Inter, Arial, sans-serif; fill: #f8f1df; }
.muted { fill: #c8bca6; font-size: 14px; }
.title { font-size: 22px; font-weight: 800; }
.label { font-size: 15px; font-weight: 700; }
.panel { fill: #1b2433; stroke: #6f5535; stroke-width: 2; rx: 10; }
.tile { fill: #253244; stroke: #7d6743; stroke-width: 2; rx: 8; }
.bar-bg { fill: #2a3444; rx: 7; }
.hp { fill: #5ed17a; rx: 7; }
.danger { fill: #d96b55; rx: 7; }
.mana { fill: #63a9ff; rx: 7; }
</style>
</defs>
<rect width="620" height="540" fill="url(#bg)"/>
<text x="20" y="36" class="title">Thor Bottom Screen - Opponent View</text>
<text x="20" y="60" class="muted">Secondary display shows real opponent health/progress.</text>
<rect x="20" y="82" width="580" height="424" class="panel"/>
<text x="44" y="122" class="title">Astra</text>
<text x="44" y="146" class="muted">Druid - live opponent</text>
<text x="376" y="122" class="label">Opponent Clear</text>
<rect x="376" y="136" width="172" height="16" class="bar-bg"/><rect x="376" y="136" width="106" height="16" class="danger"/>
<text x="44" y="192" class="label">Opponent Party Health</text>
<g transform="translate(44 216)">
<rect width="158" height="72" class="tile"/><text x="14" y="26" class="label">Tank</text><text x="104" y="26" class="muted">59%</text><rect x="14" y="44" width="128" height="12" class="bar-bg"/><rect x="14" y="44" width="76" height="12" class="danger"/>
<rect x="176" width="158" height="72" class="tile"/><text x="190" y="26" class="label">Astra</text><text x="280" y="26" class="muted">88%</text><rect x="190" y="44" width="128" height="12" class="bar-bg"/><rect x="190" y="44" width="113" height="12" class="hp"/>
<rect x="352" width="158" height="72" class="tile"/><text x="366" y="26" class="label">DPS</text><text x="456" y="26" class="muted">73%</text><rect x="366" y="44" width="128" height="12" class="bar-bg"/><rect x="366" y="44" width="93" height="12" class="hp"/>
<rect y="92" width="158" height="72" class="tile"/><text x="14" y="118" class="label">DPS</text><text x="104" y="118" class="muted">42%</text><rect x="14" y="136" width="128" height="12" class="bar-bg"/><rect x="14" y="136" width="54" height="12" class="danger"/>
<rect x="176" y="92" width="158" height="72" class="tile"/><text x="190" y="118" class="label">DPS</text><text x="280" y="118" class="muted">91%</text><rect x="190" y="136" width="128" height="12" class="bar-bg"/><rect x="190" y="136" width="116" height="12" class="hp"/>
<rect x="352" y="92" width="158" height="72" class="tile"/><text x="366" y="118" class="label">DPS</text><text x="456" y="118" class="muted">66%</text><rect x="366" y="136" width="128" height="12" class="bar-bg"/><rect x="366" y="136" width="84" height="12" class="hp"/>
</g>
<text x="44" y="420" class="label">Mana 86 / 100</text>
<rect x="154" y="408" width="214" height="16" class="bar-bg"/><rect x="154" y="408" width="184" height="16" class="mana"/>
<text x="44" y="462" class="muted">Buffs: Dense Shields x1</text>
<text x="318" y="462" class="muted">Debuffs: Cost Up x1</text>
</svg>

After

Width:  |  Height:  |  Size: 3.4 KiB

+305 -6
View File
@@ -27,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
@@ -349,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
@@ -2300,7 +2307,10 @@ function completeRoguelike(database, characterId, accountId, runMetrics) {
const bossesCleared = Number(runMetrics?.bossesCleared ?? Math.floor(encountersCleared / 3))
const experienceMode = runMetrics?.experienceMode === 'pvp-boss-quarter-level'
? 'pvp-boss-quarter-level'
: 'default'
: runMetrics?.experienceMode === 'pvp-fight-twelfth-level'
? 'pvp-fight-twelfth-level'
: 'default'
const fightsCleared = Number(runMetrics?.fightsCleared ?? encountersCleared)
const resourceSpent = Number(runMetrics?.resourceSpent)
const durationSeconds = Number(runMetrics?.durationSeconds)
if (!Number.isInteger(dungeonId) || dungeonId < 1) {
@@ -2315,6 +2325,9 @@ function completeRoguelike(database, characterId, accountId, runMetrics) {
if (!Number.isInteger(bossesCleared) || bossesCleared < 0 || bossesCleared > 100000) {
throw new Error('The roguelike boss total is invalid.')
}
if (!Number.isInteger(fightsCleared) || fightsCleared < 0 || fightsCleared > 100000) {
throw new Error('The roguelike fight total is invalid.')
}
if (!Number.isInteger(resourceSpent) || resourceSpent < 0 || resourceSpent > 100000) {
throw new Error('The run resource total is invalid.')
}
@@ -2367,14 +2380,15 @@ function completeRoguelike(database, characterId, accountId, runMetrics) {
`).get(maxLevel).experienceRequired
let newExperience = character.experience
let newLevel = character.level
if (experienceMode === 'pvp-boss-quarter-level') {
if (experienceMode === 'pvp-boss-quarter-level' || experienceMode === 'pvp-fight-twelfth-level') {
const catchUpTargetLevel = database.prepare(`
SELECT COALESCE(MAX(level), 0) AS level
FROM characters
WHERE account_id = ?
AND id != ?
`).get(accountId, characterId).level
for (let bossIndex = 0; bossIndex < bossesCleared && newExperience < maxExperience; bossIndex += 1) {
const rewardUnits = experienceMode === 'pvp-boss-quarter-level' ? bossesCleared : fightsCleared
for (let rewardIndex = 0; rewardIndex < rewardUnits && newExperience < maxExperience; rewardIndex += 1) {
const currentLevelFloor = database.prepare(`
SELECT experience_required AS experienceRequired
FROM level_progression
@@ -2388,7 +2402,9 @@ function completeRoguelike(database, characterId, accountId, runMetrics) {
WHERE level = ?
`).get(newLevel + 1).experienceRequired
const levelBand = Math.max(1, nextLevelExperience - currentLevelFloor)
const rewardRate = catchUpTargetLevel > newLevel ? 0.5 : 0.25
const rewardRate = experienceMode === 'pvp-boss-quarter-level'
? (catchUpTargetLevel > newLevel ? 0.5 : 0.25)
: (catchUpTargetLevel > newLevel ? 1 / 6 : 1 / 12)
newExperience = Math.min(maxExperience, newExperience + Math.round(levelBand * rewardRate))
newLevel = database.prepare(`
SELECT MAX(level) AS level
@@ -2538,6 +2554,246 @@ function saveProfile(database, characterId, accountId, payload) {
}
}
function cleanupPvpMemory(now = Date.now()) {
for (const [ticketId, ticket] of pvpQueue.entries()) {
if (now - ticket.updatedAt > pvpQueueTtlMs) pvpQueue.delete(ticketId)
}
for (const [matchId, match] of pvpMatches.entries()) {
if (now - match.updatedAt > pvpMatchTtlMs) pvpMatches.delete(matchId)
}
}
function validatePvpContentType(value) {
if (value !== 'dungeon' && value !== 'raid') {
throw new Error('The PvP content type is invalid.')
}
return value
}
function validatePvpStartStage(value) {
const startStage = Number(value)
if (!Number.isInteger(startStage) || startStage < 1 || startStage > 1000) {
throw new Error('The PvP start stage is invalid.')
}
return startStage
}
function pvpPlayerInfo(session) {
return {
accountId: session.accountId,
characterId: session.characterId,
characterName: session.characterName,
className: session.className,
}
}
function pvpSnapshot(match) {
return {
id: match.id,
contentType: match.contentType,
startStage: match.startStage,
createdAt: match.createdAt,
players: match.players,
states: match.states,
statuses: match.statuses,
progress: match.progress,
upgradeChoices: match.upgradeChoices,
rematchRequests: match.rematchRequests,
updatedAt: match.updatedAt,
}
}
function createPvpMatch(contentType, startStage, players, now = Date.now()) {
const matchId = randomBytes(12).toString('base64url')
const match = {
id: matchId,
contentType,
startStage,
createdAt: now,
players,
states: {},
statuses: {},
progress: {},
upgradeChoices: {},
rematchRequests: {},
updatedAt: now,
}
pvpMatches.set(matchId, match)
return match
}
function joinPvpQueue(session, payload) {
const now = Date.now()
cleanupPvpMemory(now)
const contentType = validatePvpContentType(payload.contentType)
const startStage = validatePvpStartStage(payload.startStage)
const existingTicket = [...pvpQueue.values()].find((ticket) =>
ticket.accountId === session.accountId
&& ticket.characterId === session.characterId
&& ticket.contentType === contentType
&& ticket.startStage === startStage
)
if (existingTicket?.matchId) {
const match = pvpMatches.get(existingTicket.matchId)
if (match) {
const side = match.players.a.accountId === session.accountId ? 'a' : 'b'
return { ticketId: existingTicket.id, status: 'matched', side, match: pvpSnapshot(match) }
}
}
const opponent = [...pvpQueue.values()]
.filter((ticket) =>
!ticket.matchId
&& ticket.contentType === contentType
&& ticket.startStage === startStage
&& ticket.accountId !== session.accountId
)
.sort((left, right) => left.createdAt - right.createdAt)[0]
const player = pvpPlayerInfo(session)
if (opponent) {
const match = createPvpMatch(contentType, startStage, {
a: { side: 'a', ...opponent.player },
b: { side: 'b', ...player },
}, now)
opponent.matchId = match.id
opponent.updatedAt = now
const ticketId = randomBytes(12).toString('base64url')
pvpQueue.set(ticketId, {
id: ticketId,
accountId: session.accountId,
characterId: session.characterId,
contentType,
startStage,
player,
matchId: match.id,
createdAt: now,
updatedAt: now,
})
return { ticketId, status: 'matched', side: 'b', match: pvpSnapshot(match) }
}
if (existingTicket) {
existingTicket.updatedAt = now
return { ticketId: existingTicket.id, status: 'waiting' }
}
const ticketId = randomBytes(12).toString('base64url')
pvpQueue.set(ticketId, {
id: ticketId,
accountId: session.accountId,
characterId: session.characterId,
contentType,
startStage,
player,
createdAt: now,
updatedAt: now,
})
return { ticketId, status: 'waiting' }
}
function checkPvpQueue(session, ticketId) {
cleanupPvpMemory()
const ticket = pvpQueue.get(ticketId)
if (!ticket || ticket.accountId !== session.accountId) {
const error = new Error('PvP queue ticket not found.')
error.status = 404
throw error
}
ticket.updatedAt = Date.now()
if (!ticket.matchId) return { ticketId, status: 'waiting' }
const match = pvpMatches.get(ticket.matchId)
if (!match) return { ticketId, status: 'waiting' }
const side = match.players.a.accountId === session.accountId ? 'a' : 'b'
return { ticketId, status: 'matched', side, match: pvpSnapshot(match) }
}
function cancelPvpQueue(session, ticketId) {
const ticket = pvpQueue.get(ticketId)
if (ticket && ticket.accountId === session.accountId && !ticket.matchId) {
pvpQueue.delete(ticketId)
}
return { ok: true }
}
function requirePvpMatchForSession(session, matchId) {
cleanupPvpMemory()
const match = pvpMatches.get(matchId)
if (!match) {
const error = new Error('PvP match not found.')
error.status = 404
throw error
}
const side = match.players.a.accountId === session.accountId ? 'a'
: match.players.b.accountId === session.accountId ? 'b'
: null
if (!side) {
const error = new Error('That PvP match belongs to another account.')
error.status = 403
throw error
}
return { match, side }
}
function updatePvpMatchState(session, matchId, payload) {
const { match, side } = requirePvpMatchForSession(session, matchId)
const status = ['playing', 'upgrade-choice', 'won', 'lost'].includes(payload.status)
? payload.status
: 'playing'
const progress = {
stage: validatePvpStartStage(payload.stage),
encounterIndex: Math.max(0, Math.floor(Number(payload.encounterIndex) || 0)),
encountersCleared: Math.max(0, Math.floor(Number(payload.encountersCleared) || 0)),
enemyHealth: Math.max(0, Number(payload.enemyHealth) || 0),
alive: Boolean(payload.alive),
elapsedTicks: Math.max(0, Math.floor(Number(payload.elapsedTicks) || 0)),
}
match.states[side] = payload.state ?? null
match.statuses[side] = status
match.progress[side] = progress
match.updatedAt = Date.now()
return pvpSnapshot(match)
}
function submitPvpUpgradeChoice(session, matchId, payload) {
const { match, side } = requirePvpMatchForSession(session, matchId)
const encounterIndex = Math.max(0, Math.floor(Number(payload.encounterIndex) || 0))
if (!match.upgradeChoices[side]) match.upgradeChoices[side] = {}
match.upgradeChoices[side][String(encounterIndex)] = {
encounterIndex,
buffId: String(payload.buffId ?? ''),
debuffId: String(payload.debuffId ?? ''),
}
match.updatedAt = Date.now()
return pvpSnapshot(match)
}
function requestPvpRematch(session, matchId) {
const { match, side } = requirePvpMatchForSession(session, matchId)
if (match.nextMatchId) {
const nextMatch = pvpMatches.get(match.nextMatchId)
if (nextMatch) return { status: 'matched', side, match: pvpSnapshot(nextMatch) }
}
match.rematchRequests = match.rematchRequests ?? {}
match.rematchRequests[side] = true
match.updatedAt = Date.now()
const opponentSide = side === 'a' ? 'b' : 'a'
if (!match.rematchRequests[opponentSide]) {
return { status: 'waiting', match: pvpSnapshot(match), side }
}
const nextMatch = createPvpMatch(
match.contentType,
match.startStage,
{
a: match.players.a,
b: match.players.b,
},
Date.now(),
)
match.nextMatchId = nextMatch.id
match.updatedAt = Date.now()
return { status: 'matched', side, match: pvpSnapshot(nextMatch) }
}
export function gameApiPlugin() {
return {
name: 'ashen-halls-game-api',
@@ -2688,7 +2944,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()
@@ -2727,6 +2983,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)
+406 -49
View File
@@ -774,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;
}
@@ -804,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)),
@@ -816,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,
@@ -838,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;
@@ -950,6 +1135,10 @@ textarea:focus-visible,
line-height: 1.2;
}
.dual-controls-header small {
font-size: 11px;
}
.dual-controls-progress {
font-size: 6px;
}
@@ -1023,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 {
@@ -1686,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;
}
@@ -1713,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 {
@@ -3520,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;
@@ -3538,6 +3790,7 @@ h2 {
}
.effect-slots-panel {
align-content: start;
display: grid;
gap: 10px;
grid-auto-rows: minmax(76px, auto);
@@ -3641,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;
}
@@ -4704,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;
}
@@ -5253,23 +5506,6 @@ h2 {
top: 0;
}
.member-health .health-text {
color: var(--ink);
display: none;
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;
@@ -5652,6 +5888,33 @@ h2 {
z-index: 10;
}
.pvp-round-countdown {
align-items: center;
background: rgba(5, 5, 8, 0.55);
display: flex;
inset: 0;
justify-content: center;
position: fixed;
z-index: 9;
}
.pvp-round-countdown > div {
background: var(--panel);
border: 3px solid #0b0c0f;
box-shadow: 8px 8px 0 #050507;
min-width: 220px;
outline: 2px solid var(--gold);
padding: 28px;
text-align: center;
}
.pvp-round-countdown h2 {
color: var(--gold);
font-size: clamp(48px, 8vw, 92px);
line-height: 1;
margin-top: 8px;
}
.result-screen > div,
.pause-screen > div {
background: var(--panel);
@@ -5851,27 +6114,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;
@@ -5882,18 +6135,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 {
@@ -5911,7 +6187,7 @@ h2 {
}
.pvp-side .pvp-party-grid.raid .party-member {
min-height: 62px;
min-height: 96px;
padding: 6px;
}
@@ -5948,6 +6224,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;
}
@@ -5966,26 +6265,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;
}
@@ -6020,6 +6354,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;
+1 -1
View File
@@ -1523,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
@@ -1597,6 +1596,7 @@ export function CombatScreen({
{dualScreenEnabled && (
<DualScreenTopCombat
state={dualScreenState}
onCastSpell={castSpell}
onSelectTarget={setSelectedTargetId}
/>
)}
File diff suppressed because it is too large Load Diff
+94 -5
View File
@@ -39,6 +39,13 @@ 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
@@ -431,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>
@@ -542,6 +594,8 @@ export function DualScreenBottomDisplay() {
)
})}
</section>
</>
)}
</main>
)
}
@@ -549,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,
@@ -602,7 +658,6 @@ 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
@@ -629,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>
)
}
+41 -3
View File
@@ -37,7 +37,8 @@ export interface GameRepository {
durationSeconds: number,
options?: {
bossesCleared?: number
experienceMode?: 'default' | 'pvp-boss-quarter-level'
experienceMode?: 'default' | 'pvp-boss-quarter-level' | 'pvp-fight-twelfth-level'
fightsCleared?: number
lootSourceEncounterId?: number
roguelikeStage?: number
},
@@ -503,6 +504,31 @@ function scaledPvpBossExperience(
return { experience, level }
}
function scaledPvpFightExperience(
startingExperience: number,
startingLevel: number,
fightsCleared: number,
maxLevel: number,
targetLevel = startingLevel,
) {
let experience = startingExperience
let level = startingLevel
const maxExperience = experienceForLevel(maxLevel)
for (let fightIndex = 0; fightIndex < fightsCleared && experience < maxExperience; fightIndex += 1) {
const currentLevelFloor = experienceForLevel(level)
const nextLevelExperience = level >= maxLevel
? maxExperience
: experienceForLevel(level + 1)
const levelBand = Math.max(1, nextLevelExperience - currentLevelFloor)
const rewardRate = targetLevel > level ? 1 / 6 : 1 / 12
experience = Math.min(maxExperience, experience + Math.round(levelBand * rewardRate))
while (level < maxLevel && experienceForLevel(level + 1) <= experience) {
level += 1
}
}
return { experience, level }
}
function talentEffectCapacity(level: number) {
return Math.min(4, Math.max(0, Math.floor(level / 5)))
}
@@ -671,7 +697,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)
@@ -696,6 +722,10 @@ 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)
}
@@ -1138,7 +1168,15 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
const bossesCleared = Math.max(0, Math.floor(options?.bossesCleared ?? encountersCleared / 3))
const scaledReward = options?.experienceMode === 'pvp-boss-quarter-level'
? scaledPvpBossExperience(previousExperience, previousLevel, bossesCleared, profile.maxLevel, highestOtherClassLevel(save))
: null
: options?.experienceMode === 'pvp-fight-twelfth-level'
? scaledPvpFightExperience(
previousExperience,
previousLevel,
Math.max(0, Math.floor(options.fightsCleared ?? encountersCleared)),
profile.maxLevel,
highestOtherClassLevel(save),
)
: null
const baseRoguelikeReward = Math.round(dungeon.experienceReward * difficulty.experienceMultiplier * (encountersCleared / 3))
const newExperience = scaledReward
? scaledReward.experience
+2 -1
View File
@@ -349,7 +349,8 @@ export async function completeRoguelike(
durationSeconds: number,
options?: {
bossesCleared?: number
experienceMode?: 'default' | 'pvp-boss-quarter-level'
experienceMode?: 'default' | 'pvp-boss-quarter-level' | 'pvp-fight-twelfth-level'
fightsCleared?: number
lootSourceEncounterId?: number
roguelikeStage?: number
},
+113
View File
@@ -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',
})
}