Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 421540c52b | |||
| 1e24aecad8 | |||
| c9fb28ab6d | |||
| c1e2c6d8b5 |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -7,8 +7,8 @@ android {
|
|||||||
applicationId "com.warren.iwanttoheal"
|
applicationId "com.warren.iwanttoheal"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 68
|
versionCode 72
|
||||||
versionName "1.0.49"
|
versionName "1.0.53"
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
aaptOptions {
|
aaptOptions {
|
||||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1440" height="900" viewBox="0 0 1440 900">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" x2="1" y1="0" y2="1"><stop offset="0" stop-color="#111827"/><stop offset="1" stop-color="#2f1f16"/></linearGradient>
|
||||||
|
<style>
|
||||||
|
text{font-family:Inter,Arial,sans-serif;fill:#f8f1df}.muted{fill:#c8bca6;font-size:20px}.tiny{fill:#d8cab1;font-size:16px}.title{font-size:30px;font-weight:800}.label{font-size:19px;font-weight:700}.panel{fill:#1b2433;stroke:#6f5535;stroke-width:2;rx:12}.tile{fill:#253244;stroke:#7d6743;stroke-width:2;rx:10}.selected{fill:#31483b;stroke:#d7aa45;stroke-width:4;rx:10}.bar-bg{fill:#2a3444;rx:8}.hp{fill:#5ed17a;rx:8}.danger{fill:#d96b55;rx:8}.mana{fill:#63a9ff;rx:8}.button{fill:#d7aa45;rx:10}.spell{fill:#273449;stroke:#8d7349;stroke-width:2;rx:10}
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<rect width="1440" height="900" fill="url(#bg)"/>
|
||||||
|
<text x="48" y="64" class="title">PC Dungeon PvP Roguelike</text>
|
||||||
|
<text x="48" y="96" class="muted">Dungeon party = 6 members per side. Upgrade timer auto-picks at 0s.</text>
|
||||||
|
|
||||||
|
<rect x="36" y="132" width="402" height="704" class="panel"/>
|
||||||
|
<text x="64" y="176" class="title">You</text><text x="64" y="204" class="muted">Mira - Priest</text>
|
||||||
|
<text x="64" y="252" class="label">Party Health</text>
|
||||||
|
<g transform="translate(64 278)">
|
||||||
|
<rect width="150" height="82" class="selected"/><text x="16" y="31" class="label">Tank</text><text x="106" y="31" class="tiny">82%</text><rect x="16" y="52" width="118" height="12" class="bar-bg"/><rect x="16" y="52" width="97" height="12" class="hp"/>
|
||||||
|
<rect x="170" width="150" height="82" class="tile"/><text x="186" y="31" class="label">Mira</text><text x="276" y="31" class="tiny">74%</text><rect x="186" y="52" width="118" height="12" class="bar-bg"/><rect x="186" y="52" width="87" height="12" class="hp"/>
|
||||||
|
<rect y="104" width="150" height="82" class="tile"/><text x="16" y="135" class="label">DPS</text><text x="106" y="135" class="tiny">55%</text><rect x="16" y="156" width="118" height="12" class="bar-bg"/><rect x="16" y="156" width="65" height="12" class="danger"/>
|
||||||
|
<rect x="170" y="104" width="150" height="82" class="tile"/><text x="186" y="135" class="label">DPS</text><text x="276" y="135" class="tiny">92%</text><rect x="186" y="156" width="118" height="12" class="bar-bg"/><rect x="186" y="156" width="109" height="12" class="hp"/>
|
||||||
|
<rect y="208" width="150" height="82" class="tile"/><text x="16" y="239" class="label">DPS</text><text x="106" y="239" class="tiny">66%</text><rect x="16" y="260" width="118" height="12" class="bar-bg"/><rect x="16" y="260" width="78" height="12" class="hp"/>
|
||||||
|
<rect x="170" y="208" width="150" height="82" class="tile"/><text x="186" y="239" class="label">DPS</text><text x="276" y="239" class="tiny">41%</text><rect x="186" y="260" width="118" height="12" class="bar-bg"/><rect x="186" y="260" width="48" height="12" class="danger"/>
|
||||||
|
</g>
|
||||||
|
<text x="64" y="626" class="tiny">Buffs: Wide Radiance x1</text><text x="64" y="654" class="tiny">Debuffs: Mana Squeeze x1</text>
|
||||||
|
|
||||||
|
<rect x="472" y="132" width="496" height="704" class="panel"/>
|
||||||
|
<text x="504" y="176" class="title">Stage 15 Boss</text><text x="504" y="204" class="muted">Bulldrome Guardian</text>
|
||||||
|
<text x="504" y="260" class="label">Your Clear</text><rect x="504" y="278" width="400" height="24" class="bar-bg"/><rect x="504" y="278" width="176" height="24" class="danger"/>
|
||||||
|
<text x="504" y="342" class="label">Astra Clear</text><rect x="504" y="360" width="400" height="24" class="bar-bg"/><rect x="504" y="360" width="246" height="24" class="danger"/>
|
||||||
|
<g transform="translate(520 436)">
|
||||||
|
<rect width="400" height="260" fill="#161f2c" stroke="#d7aa45" stroke-width="3" rx="14"/>
|
||||||
|
<text x="32" y="44" class="title">Choose Edge</text><text x="274" y="44" class="title">07.4s</text>
|
||||||
|
<text x="32" y="88" class="label">Self Buff</text><text x="210" y="88" class="label">Opponent Debuff</text>
|
||||||
|
<rect x="32" y="112" width="150" height="52" class="selected"/><text x="46" y="145" class="tiny">+1 Target</text>
|
||||||
|
<rect x="32" y="178" width="150" height="52" class="tile"/><text x="46" y="211" class="tiny">-25% Cost</text>
|
||||||
|
<rect x="210" y="112" width="150" height="52" class="tile"/><text x="224" y="145" class="tiny">Cost Up</text>
|
||||||
|
<rect x="210" y="178" width="150" height="52" class="selected"/><text x="224" y="211" class="tiny">Mana Squeeze</text>
|
||||||
|
</g>
|
||||||
|
<g transform="translate(520 728)">
|
||||||
|
<rect width="56" height="52" class="spell"/><text x="22" y="34" font-size="24" font-weight="800">+</text>
|
||||||
|
<rect x="68" width="56" height="52" class="spell"/><text x="88" y="34" font-size="24" font-weight="800">R</text>
|
||||||
|
<rect x="136" width="56" height="52" class="spell"/><text x="156" y="34" font-size="24" font-weight="800">S</text>
|
||||||
|
<rect x="204" width="56" height="52" class="spell"/><text x="224" y="34" font-size="24" font-weight="800">G</text>
|
||||||
|
<rect x="272" width="56" height="52" class="spell"/><text x="292" y="34" font-size="24" font-weight="800">C</text>
|
||||||
|
<rect x="340" width="56" height="52" class="spell"/><text x="360" y="34" font-size="24" font-weight="800">B</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<rect x="1002" y="132" width="402" height="704" class="panel"/>
|
||||||
|
<text x="1030" y="176" class="title">Opponent</text><text x="1030" y="204" class="muted">Astra - Druid</text>
|
||||||
|
<text x="1030" y="252" class="label">Opponent Party</text>
|
||||||
|
<g transform="translate(1030 278)">
|
||||||
|
<rect width="150" height="82" class="tile"/><text x="16" y="31" class="label">Tank</text><text x="106" y="31" class="tiny">59%</text><rect x="16" y="52" width="118" height="12" class="bar-bg"/><rect x="16" y="52" width="70" height="12" class="danger"/>
|
||||||
|
<rect x="170" width="150" height="82" class="tile"/><text x="186" y="31" class="label">Astra</text><text x="276" y="31" class="tiny">88%</text><rect x="186" y="52" width="118" height="12" class="bar-bg"/><rect x="186" y="52" width="104" height="12" class="hp"/>
|
||||||
|
<rect y="104" width="150" height="82" class="tile"/><text x="16" y="135" class="label">DPS</text><text x="106" y="135" class="tiny">73%</text><rect x="16" y="156" width="118" height="12" class="bar-bg"/><rect x="16" y="156" width="86" height="12" class="hp"/>
|
||||||
|
<rect x="170" y="104" width="150" height="82" class="tile"/><text x="186" y="135" class="label">DPS</text><text x="276" y="135" class="tiny">42%</text><rect x="186" y="156" width="118" height="12" class="bar-bg"/><rect x="186" y="156" width="50" height="12" class="danger"/>
|
||||||
|
<rect y="208" width="150" height="82" class="tile"/><text x="16" y="239" class="label">DPS</text><text x="106" y="239" class="tiny">91%</text><rect x="16" y="260" width="118" height="12" class="bar-bg"/><rect x="16" y="260" width="107" height="12" class="hp"/>
|
||||||
|
<rect x="170" y="208" width="150" height="82" class="tile"/><text x="186" y="239" class="label">DPS</text><text x="276" y="239" class="tiny">66%</text><rect x="186" y="260" width="118" height="12" class="bar-bg"/><rect x="186" y="260" width="78" height="12" class="hp"/>
|
||||||
|
</g>
|
||||||
|
<text x="1030" y="626" class="tiny">Buffs: Dense Shields x1</text><text x="1030" y="654" class="tiny">Debuffs: Cost Up x1</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 7.1 KiB |
@@ -0,0 +1,30 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540" viewBox="0 0 960 540">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" x2="1" y1="0" y2="1"><stop offset="0" stop-color="#101827"/><stop offset="1" stop-color="#271a12"/></linearGradient>
|
||||||
|
<style>
|
||||||
|
text{font-family:Inter,Arial,sans-serif;fill:#f8f1df}.muted{fill:#c8bca6;font-size:15px}.title{font-size:24px;font-weight:800}.label{font-size:16px;font-weight:700}.panel{fill:#1b2433;stroke:#6f5535;stroke-width:2;rx:10}.tile{fill:#253244;stroke:#7d6743;stroke-width:2;rx:8}.selected{fill:#31483b;stroke:#d7aa45;stroke-width:4;rx:8}.bar-bg{fill:#2a3444;rx:7}.hp{fill:#5ed17a;rx:7}.danger{fill:#d96b55;rx:7}.mana{fill:#63a9ff;rx:7}.spell{fill:#273449;stroke:#8d7349;stroke-width:2;rx:10}.ready{fill:#31483b;stroke:#d7aa45;stroke-width:3;rx:10}
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<rect width="960" height="540" fill="url(#bg)"/>
|
||||||
|
<text x="24" y="38" class="title">Thor Main - Dungeon PvP</text>
|
||||||
|
<text x="24" y="62" class="muted">Dungeon party shows exactly 6 members. Opponent health stays on bottom screen.</text>
|
||||||
|
<rect x="24" y="86" width="912" height="350" class="panel"/>
|
||||||
|
<text x="52" y="124" class="title">Mira Party</text><text x="760" y="124" class="label">Stage 15 Boss</text>
|
||||||
|
<rect x="760" y="140" width="132" height="16" class="bar-bg"/><rect x="760" y="140" width="58" height="16" class="danger"/>
|
||||||
|
<g transform="translate(52 164)">
|
||||||
|
<rect width="132" height="96" class="selected"/><text x="16" y="28" class="label">Tank</text><text x="16" y="52" class="muted">82 / 100</text><rect x="16" y="68" width="100" height="12" class="bar-bg"/><rect x="16" y="68" width="82" height="12" class="hp"/>
|
||||||
|
<rect x="150" width="132" height="96" class="tile"/><text x="166" y="28" class="label">Mira</text><text x="166" y="52" class="muted">74 / 100</text><rect x="166" y="68" width="100" height="12" class="bar-bg"/><rect x="166" y="68" width="74" height="12" class="hp"/>
|
||||||
|
<rect x="300" width="132" height="96" class="tile"/><text x="316" y="28" class="label">DPS</text><text x="316" y="52" class="muted">55 / 100</text><rect x="316" y="68" width="100" height="12" class="bar-bg"/><rect x="316" y="68" width="55" height="12" class="danger"/>
|
||||||
|
<rect y="116" width="132" height="96" class="tile"/><text x="16" y="144" class="label">DPS</text><text x="16" y="168" class="muted">92 / 100</text><rect x="16" y="184" width="100" height="12" class="bar-bg"/><rect x="16" y="184" width="92" height="12" class="hp"/>
|
||||||
|
<rect x="150" y="116" width="132" height="96" class="tile"/><text x="166" y="144" class="label">DPS</text><text x="166" y="168" class="muted">66 / 100</text><rect x="166" y="184" width="100" height="12" class="bar-bg"/><rect x="166" y="184" width="66" height="12" class="hp"/>
|
||||||
|
<rect x="300" y="116" width="132" height="96" class="tile"/><text x="316" y="144" class="label">DPS</text><text x="316" y="168" class="muted">41 / 100</text><rect x="316" y="184" width="100" height="12" class="bar-bg"/><rect x="316" y="184" width="41" height="12" class="danger"/>
|
||||||
|
</g>
|
||||||
|
<rect x="24" y="452" width="912" height="68" class="panel"/>
|
||||||
|
<rect x="48" y="468" width="52" height="40" class="ready"/><text x="66" y="496" font-size="22" font-weight="800">+</text>
|
||||||
|
<rect x="112" y="468" width="52" height="40" class="spell"/><text x="129" y="496" font-size="22" font-weight="800">R</text><text x="143" y="483" font-size="12">3</text>
|
||||||
|
<rect x="176" y="468" width="52" height="40" class="spell"/><text x="194" y="496" font-size="22" font-weight="800">S</text><text x="207" y="483" font-size="12">8</text>
|
||||||
|
<rect x="240" y="468" width="52" height="40" class="ready"/><text x="258" y="496" font-size="22" font-weight="800">G</text>
|
||||||
|
<rect x="304" y="468" width="52" height="40" class="spell"/><text x="322" y="496" font-size="22" font-weight="800">C</text><text x="335" y="483" font-size="12">5</text>
|
||||||
|
<rect x="368" y="468" width="52" height="40" class="ready"/><text x="386" y="496" font-size="22" font-weight="800">B</text>
|
||||||
|
<text x="650" y="480" class="label">Mana 73 / 100</text><rect x="650" y="492" width="240" height="14" class="bar-bg"/><rect x="650" y="492" width="175" height="14" class="mana"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.1 KiB |
@@ -0,0 +1,62 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1440" height="900" viewBox="0 0 1440 900">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" x2="1" y1="0" y2="1"><stop offset="0" stop-color="#111827"/><stop offset="1" stop-color="#2f1f16"/></linearGradient>
|
||||||
|
<style>
|
||||||
|
text{font-family:Inter,Arial,sans-serif;fill:#f8f1df}.muted{fill:#c8bca6;font-size:20px}.tiny{fill:#d8cab1;font-size:14px}.title{font-size:30px;font-weight:800}.label{font-size:18px;font-weight:700}.panel{fill:#1b2433;stroke:#6f5535;stroke-width:2;rx:12}.tile{fill:#253244;stroke:#7d6743;stroke-width:2;rx:8}.bar-bg{fill:#2a3444;rx:7}.hp{fill:#5ed17a;rx:7}.danger{fill:#d96b55;rx:7}.spell{fill:#273449;stroke:#8d7349;stroke-width:2;rx:10}.selected{fill:#31483b;stroke:#d7aa45;stroke-width:3;rx:8}
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<rect width="1440" height="900" fill="url(#bg)"/>
|
||||||
|
<text x="48" y="64" class="title">PC Raid PvP Roguelike</text>
|
||||||
|
<text x="48" y="96" class="muted">Raid party = compact roster. Opponent panel mirrors raid health without crowding center.</text>
|
||||||
|
<rect x="36" y="132" width="430" height="704" class="panel"/>
|
||||||
|
<text x="64" y="176" class="title">You</text><text x="64" y="204" class="muted">Mira Raid Group</text>
|
||||||
|
<g transform="translate(64 238)">
|
||||||
|
<rect width="104" height="56" class="selected"/><text x="10" y="24" class="tiny">Tank 82%</text><rect x="10" y="36" width="84" height="8" class="bar-bg"/><rect x="10" y="36" width="69" height="8" class="hp"/>
|
||||||
|
<rect x="116" width="104" height="56" class="tile"/><text x="126" y="24" class="tiny">Mira 74%</text><rect x="126" y="36" width="84" height="8" class="bar-bg"/><rect x="126" y="36" width="62" height="8" class="hp"/>
|
||||||
|
<rect x="232" width="104" height="56" class="tile"/><text x="242" y="24" class="tiny">DPS 55%</text><rect x="242" y="36" width="84" height="8" class="bar-bg"/><rect x="242" y="36" width="46" height="8" class="danger"/>
|
||||||
|
<rect y="72" width="104" height="56" class="tile"/><text x="10" y="96" class="tiny">DPS 92%</text><rect x="10" y="108" width="84" height="8" class="bar-bg"/><rect x="10" y="108" width="77" height="8" class="hp"/>
|
||||||
|
<rect x="116" y="72" width="104" height="56" class="tile"/><text x="126" y="96" class="tiny">DPS 66%</text><rect x="126" y="108" width="84" height="8" class="bar-bg"/><rect x="126" y="108" width="55" height="8" class="hp"/>
|
||||||
|
<rect x="232" y="72" width="104" height="56" class="tile"/><text x="242" y="96" class="tiny">DPS 41%</text><rect x="242" y="108" width="84" height="8" class="bar-bg"/><rect x="242" y="108" width="34" height="8" class="danger"/>
|
||||||
|
<rect y="144" width="104" height="56" class="tile"/><text x="10" y="168" class="tiny">DPS 88%</text><rect x="10" y="180" width="84" height="8" class="bar-bg"/><rect x="10" y="180" width="74" height="8" class="hp"/>
|
||||||
|
<rect x="116" y="144" width="104" height="56" class="tile"/><text x="126" y="168" class="tiny">DPS 63%</text><rect x="126" y="180" width="84" height="8" class="bar-bg"/><rect x="126" y="180" width="53" height="8" class="hp"/>
|
||||||
|
<rect x="232" y="144" width="104" height="56" class="tile"/><text x="242" y="168" class="tiny">DPS 77%</text><rect x="242" y="180" width="84" height="8" class="bar-bg"/><rect x="242" y="180" width="65" height="8" class="hp"/>
|
||||||
|
<rect y="216" width="104" height="56" class="tile"/><text x="10" y="240" class="tiny">DPS 49%</text><rect x="10" y="252" width="84" height="8" class="bar-bg"/><rect x="10" y="252" width="41" height="8" class="danger"/>
|
||||||
|
<rect x="116" y="216" width="104" height="56" class="tile"/><text x="126" y="240" class="tiny">DPS 96%</text><rect x="126" y="252" width="84" height="8" class="bar-bg"/><rect x="126" y="252" width="81" height="8" class="hp"/>
|
||||||
|
<rect x="232" y="216" width="104" height="56" class="tile"/><text x="242" y="240" class="tiny">DPS 70%</text><rect x="242" y="252" width="84" height="8" class="bar-bg"/><rect x="242" y="252" width="59" height="8" class="hp"/>
|
||||||
|
</g>
|
||||||
|
<text x="64" y="590" class="tiny">Direct target group: 1 / 2</text><text x="64" y="620" class="tiny">Buffs: Wide Radiance x1</text>
|
||||||
|
|
||||||
|
<rect x="502" y="132" width="436" height="704" class="panel"/>
|
||||||
|
<text x="534" y="176" class="title">Raid Stage 20 Boss</text><text x="534" y="204" class="muted">Ashen Warden</text>
|
||||||
|
<text x="534" y="260" class="label">Your Clear</text><rect x="534" y="278" width="340" height="24" class="bar-bg"/><rect x="534" y="278" width="176" height="24" class="danger"/>
|
||||||
|
<text x="534" y="342" class="label">Astra Clear</text><rect x="534" y="360" width="340" height="24" class="bar-bg"/><rect x="534" y="360" width="218" height="24" class="danger"/>
|
||||||
|
<rect x="534" y="438" width="340" height="220" fill="#161f2c" stroke="#d7aa45" stroke-width="3" rx="14"/>
|
||||||
|
<text x="558" y="482" class="title">Choose Edge</text><text x="766" y="482" class="title">10.0s</text>
|
||||||
|
<rect x="558" y="520" width="132" height="48" class="selected"/><text x="572" y="551" class="tiny">Raid Heal +</text>
|
||||||
|
<rect x="708" y="520" width="132" height="48" class="tile"/><text x="722" y="551" class="tiny">Mana Squeeze</text>
|
||||||
|
<rect x="558" y="584" width="132" height="48" class="tile"/><text x="572" y="615" class="tiny">Shield Boost</text>
|
||||||
|
<rect x="708" y="584" width="132" height="48" class="selected"/><text x="722" y="615" class="tiny">Cooldown Up</text>
|
||||||
|
<g transform="translate(534 724)">
|
||||||
|
<rect width="48" height="48" class="spell"/><text x="18" y="32" font-size="22" font-weight="800">+</text>
|
||||||
|
<rect x="60" width="48" height="48" class="spell"/><text x="77" y="32" font-size="22" font-weight="800">R</text>
|
||||||
|
<rect x="120" width="48" height="48" class="spell"/><text x="137" y="32" font-size="22" font-weight="800">S</text>
|
||||||
|
<rect x="180" width="48" height="48" class="spell"/><text x="197" y="32" font-size="22" font-weight="800">G</text>
|
||||||
|
<rect x="240" width="48" height="48" class="spell"/><text x="257" y="32" font-size="22" font-weight="800">C</text>
|
||||||
|
<rect x="300" width="48" height="48" class="spell"/><text x="317" y="32" font-size="22" font-weight="800">B</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<rect x="974" y="132" width="430" height="704" class="panel"/>
|
||||||
|
<text x="1002" y="176" class="title">Opponent Raid</text><text x="1002" y="204" class="muted">Astra Group</text>
|
||||||
|
<g transform="translate(1002 238)">
|
||||||
|
<rect width="104" height="56" class="tile"/><text x="10" y="24" class="tiny">Tank 61%</text><rect x="10" y="36" width="84" height="8" class="bar-bg"/><rect x="10" y="36" width="51" height="8" class="danger"/>
|
||||||
|
<rect x="116" width="104" height="56" class="tile"/><text x="126" y="24" class="tiny">Astra 89%</text><rect x="126" y="36" width="84" height="8" class="bar-bg"/><rect x="126" y="36" width="75" height="8" class="hp"/>
|
||||||
|
<rect x="232" width="104" height="56" class="tile"/><text x="242" y="24" class="tiny">DPS 73%</text><rect x="242" y="36" width="84" height="8" class="bar-bg"/><rect x="242" y="36" width="61" height="8" class="hp"/>
|
||||||
|
<rect y="72" width="104" height="56" class="tile"/><text x="10" y="96" class="tiny">DPS 42%</text><rect x="10" y="108" width="84" height="8" class="bar-bg"/><rect x="10" y="108" width="35" height="8" class="danger"/>
|
||||||
|
<rect x="116" y="72" width="104" height="56" class="tile"/><text x="126" y="96" class="tiny">DPS 91%</text><rect x="126" y="108" width="84" height="8" class="bar-bg"/><rect x="126" y="108" width="76" height="8" class="hp"/>
|
||||||
|
<rect x="232" y="72" width="104" height="56" class="tile"/><text x="242" y="96" class="tiny">DPS 66%</text><rect x="242" y="108" width="84" height="8" class="bar-bg"/><rect x="242" y="108" width="55" height="8" class="hp"/>
|
||||||
|
<rect y="144" width="104" height="56" class="tile"/><text x="10" y="168" class="tiny">DPS 79%</text><rect x="10" y="180" width="84" height="8" class="bar-bg"/><rect x="10" y="180" width="66" height="8" class="hp"/>
|
||||||
|
<rect x="116" y="144" width="104" height="56" class="tile"/><text x="126" y="168" class="tiny">DPS 58%</text><rect x="126" y="180" width="84" height="8" class="bar-bg"/><rect x="126" y="180" width="49" height="8" class="danger"/>
|
||||||
|
<rect x="232" y="144" width="104" height="56" class="tile"/><text x="242" y="168" class="tiny">DPS 84%</text><rect x="242" y="180" width="84" height="8" class="bar-bg"/><rect x="242" y="180" width="71" height="8" class="hp"/>
|
||||||
|
</g>
|
||||||
|
<text x="1002" y="590" class="tiny">Opponent buffs/debuffs visible here</text><text x="1002" y="620" class="tiny">Raid bottom screen can scroll second group if needed</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 8.3 KiB |
@@ -0,0 +1,36 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540" viewBox="0 0 960 540">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" x2="1" y1="0" y2="1"><stop offset="0" stop-color="#101827"/><stop offset="1" stop-color="#271a12"/></linearGradient>
|
||||||
|
<style>
|
||||||
|
text{font-family:Inter,Arial,sans-serif;fill:#f8f1df}.muted{fill:#c8bca6;font-size:15px}.title{font-size:24px;font-weight:800}.label{font-size:15px;font-weight:700}.panel{fill:#1b2433;stroke:#6f5535;stroke-width:2;rx:10}.tile{fill:#253244;stroke:#7d6743;stroke-width:2;rx:8}.selected{fill:#31483b;stroke:#d7aa45;stroke-width:4;rx:8}.bar-bg{fill:#2a3444;rx:7}.hp{fill:#5ed17a;rx:7}.danger{fill:#d96b55;rx:7}.mana{fill:#63a9ff;rx:7}.spell{fill:#273449;stroke:#8d7349;stroke-width:2;rx:10}.ready{fill:#31483b;stroke:#d7aa45;stroke-width:3;rx:10}
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<rect width="960" height="540" fill="url(#bg)"/>
|
||||||
|
<text x="24" y="38" class="title">Thor Main - Raid PvP</text>
|
||||||
|
<text x="24" y="62" class="muted">Raid party uses compact tiles plus target group toggle. Bottom screen mirrors opponent raid.</text>
|
||||||
|
<rect x="24" y="86" width="912" height="350" class="panel"/>
|
||||||
|
<text x="52" y="122" class="title">Mira Raid</text><text x="214" y="122" class="muted">Target Group 1 / 2</text>
|
||||||
|
<text x="760" y="122" class="label">Raid Boss</text><rect x="760" y="138" width="132" height="16" class="bar-bg"/><rect x="760" y="138" width="72" height="16" class="danger"/>
|
||||||
|
<g transform="translate(52 156)">
|
||||||
|
<rect width="104" height="72" class="selected"/><text x="12" y="28" class="label">Tank</text><text x="62" y="28" class="muted">82%</text><rect x="12" y="46" width="80" height="10" class="bar-bg"/><rect x="12" y="46" width="66" height="10" class="hp"/>
|
||||||
|
<rect x="118" width="104" height="72" class="tile"/><text x="130" y="28" class="label">Mira</text><text x="180" y="28" class="muted">74%</text><rect x="130" y="46" width="80" height="10" class="bar-bg"/><rect x="130" y="46" width="59" height="10" class="hp"/>
|
||||||
|
<rect x="236" width="104" height="72" class="tile"/><text x="248" y="28" class="label">DPS</text><text x="298" y="28" class="muted">55%</text><rect x="248" y="46" width="80" height="10" class="bar-bg"/><rect x="248" y="46" width="44" height="10" class="danger"/>
|
||||||
|
<rect x="354" width="104" height="72" class="tile"/><text x="366" y="28" class="label">DPS</text><text x="416" y="28" class="muted">92%</text><rect x="366" y="46" width="80" height="10" class="bar-bg"/><rect x="366" y="46" width="74" height="10" class="hp"/>
|
||||||
|
<rect x="472" width="104" height="72" class="tile"/><text x="484" y="28" class="label">DPS</text><text x="534" y="28" class="muted">66%</text><rect x="484" y="46" width="80" height="10" class="bar-bg"/><rect x="484" y="46" width="53" height="10" class="hp"/>
|
||||||
|
<rect x="590" width="104" height="72" class="tile"/><text x="602" y="28" class="label">DPS</text><text x="652" y="28" class="muted">41%</text><rect x="602" y="46" width="80" height="10" class="bar-bg"/><rect x="602" y="46" width="33" height="10" class="danger"/>
|
||||||
|
<rect y="92" width="104" height="72" class="tile"/><text x="12" y="120" class="label">DPS</text><text x="62" y="120" class="muted">88%</text><rect x="12" y="138" width="80" height="10" class="bar-bg"/><rect x="12" y="138" width="70" height="10" class="hp"/>
|
||||||
|
<rect x="118" y="92" width="104" height="72" class="tile"/><text x="130" y="120" class="label">DPS</text><text x="180" y="120" class="muted">63%</text><rect x="130" y="138" width="80" height="10" class="bar-bg"/><rect x="130" y="138" width="50" height="10" class="hp"/>
|
||||||
|
<rect x="236" y="92" width="104" height="72" class="tile"/><text x="248" y="120" class="label">DPS</text><text x="298" y="120" class="muted">77%</text><rect x="248" y="138" width="80" height="10" class="bar-bg"/><rect x="248" y="138" width="62" height="10" class="hp"/>
|
||||||
|
<rect x="354" y="92" width="104" height="72" class="tile"/><text x="366" y="120" class="label">DPS</text><text x="416" y="120" class="muted">49%</text><rect x="366" y="138" width="80" height="10" class="bar-bg"/><rect x="366" y="138" width="39" height="10" class="danger"/>
|
||||||
|
<rect x="472" y="92" width="104" height="72" class="tile"/><text x="484" y="120" class="label">DPS</text><text x="534" y="120" class="muted">96%</text><rect x="484" y="138" width="80" height="10" class="bar-bg"/><rect x="484" y="138" width="77" height="10" class="hp"/>
|
||||||
|
<rect x="590" y="92" width="104" height="72" class="tile"/><text x="602" y="120" class="label">DPS</text><text x="652" y="120" class="muted">70%</text><rect x="602" y="138" width="80" height="10" class="bar-bg"/><rect x="602" y="138" width="56" height="10" class="hp"/>
|
||||||
|
</g>
|
||||||
|
<rect x="24" y="452" width="912" height="68" class="panel"/>
|
||||||
|
<rect x="48" y="468" width="52" height="40" class="ready"/><text x="66" y="496" font-size="22" font-weight="800">+</text>
|
||||||
|
<rect x="112" y="468" width="52" height="40" class="spell"/><text x="129" y="496" font-size="22" font-weight="800">R</text><text x="143" y="483" font-size="12">3</text>
|
||||||
|
<rect x="176" y="468" width="52" height="40" class="spell"/><text x="194" y="496" font-size="22" font-weight="800">S</text><text x="207" y="483" font-size="12">8</text>
|
||||||
|
<rect x="240" y="468" width="52" height="40" class="ready"/><text x="258" y="496" font-size="22" font-weight="800">G</text>
|
||||||
|
<rect x="304" y="468" width="52" height="40" class="spell"/><text x="322" y="496" font-size="22" font-weight="800">C</text><text x="335" y="483" font-size="12">5</text>
|
||||||
|
<rect x="368" y="468" width="52" height="40" class="ready"/><text x="386" y="496" font-size="22" font-weight="800">B</text>
|
||||||
|
<text x="650" y="480" class="label">Mana 73 / 100</text><rect x="650" y="492" width="240" height="14" class="bar-bg"/><rect x="650" y="492" width="175" height="14" class="mana"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 5.7 KiB |
@@ -0,0 +1,41 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="620" height="540" viewBox="0 0 620 540">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" x2="1" y1="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#101827"/>
|
||||||
|
<stop offset="1" stop-color="#271a12"/>
|
||||||
|
</linearGradient>
|
||||||
|
<style>
|
||||||
|
text { font-family: Inter, Arial, sans-serif; fill: #f8f1df; }
|
||||||
|
.muted { fill: #c8bca6; font-size: 14px; }
|
||||||
|
.title { font-size: 22px; font-weight: 800; }
|
||||||
|
.label { font-size: 15px; font-weight: 700; }
|
||||||
|
.panel { fill: #1b2433; stroke: #6f5535; stroke-width: 2; rx: 10; }
|
||||||
|
.tile { fill: #253244; stroke: #7d6743; stroke-width: 2; rx: 8; }
|
||||||
|
.bar-bg { fill: #2a3444; rx: 7; }
|
||||||
|
.hp { fill: #5ed17a; rx: 7; }
|
||||||
|
.danger { fill: #d96b55; rx: 7; }
|
||||||
|
.mana { fill: #63a9ff; rx: 7; }
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<rect width="620" height="540" fill="url(#bg)"/>
|
||||||
|
<text x="20" y="36" class="title">Thor Bottom Screen - Opponent View</text>
|
||||||
|
<text x="20" y="60" class="muted">Secondary display shows real opponent health/progress.</text>
|
||||||
|
<rect x="20" y="82" width="580" height="424" class="panel"/>
|
||||||
|
<text x="44" y="122" class="title">Astra</text>
|
||||||
|
<text x="44" y="146" class="muted">Druid - live opponent</text>
|
||||||
|
<text x="376" y="122" class="label">Opponent Clear</text>
|
||||||
|
<rect x="376" y="136" width="172" height="16" class="bar-bg"/><rect x="376" y="136" width="106" height="16" class="danger"/>
|
||||||
|
<text x="44" y="192" class="label">Opponent Party Health</text>
|
||||||
|
<g transform="translate(44 216)">
|
||||||
|
<rect width="158" height="72" class="tile"/><text x="14" y="26" class="label">Tank</text><text x="104" y="26" class="muted">59%</text><rect x="14" y="44" width="128" height="12" class="bar-bg"/><rect x="14" y="44" width="76" height="12" class="danger"/>
|
||||||
|
<rect x="176" width="158" height="72" class="tile"/><text x="190" y="26" class="label">Astra</text><text x="280" y="26" class="muted">88%</text><rect x="190" y="44" width="128" height="12" class="bar-bg"/><rect x="190" y="44" width="113" height="12" class="hp"/>
|
||||||
|
<rect x="352" width="158" height="72" class="tile"/><text x="366" y="26" class="label">DPS</text><text x="456" y="26" class="muted">73%</text><rect x="366" y="44" width="128" height="12" class="bar-bg"/><rect x="366" y="44" width="93" height="12" class="hp"/>
|
||||||
|
<rect y="92" width="158" height="72" class="tile"/><text x="14" y="118" class="label">DPS</text><text x="104" y="118" class="muted">42%</text><rect x="14" y="136" width="128" height="12" class="bar-bg"/><rect x="14" y="136" width="54" height="12" class="danger"/>
|
||||||
|
<rect x="176" y="92" width="158" height="72" class="tile"/><text x="190" y="118" class="label">DPS</text><text x="280" y="118" class="muted">91%</text><rect x="190" y="136" width="128" height="12" class="bar-bg"/><rect x="190" y="136" width="116" height="12" class="hp"/>
|
||||||
|
<rect x="352" y="92" width="158" height="72" class="tile"/><text x="366" y="118" class="label">DPS</text><text x="456" y="118" class="muted">66%</text><rect x="366" y="136" width="128" height="12" class="bar-bg"/><rect x="366" y="136" width="84" height="12" class="hp"/>
|
||||||
|
</g>
|
||||||
|
<text x="44" y="420" class="label">Mana 86 / 100</text>
|
||||||
|
<rect x="154" y="408" width="214" height="16" class="bar-bg"/><rect x="154" y="408" width="184" height="16" class="mana"/>
|
||||||
|
<text x="44" y="462" class="muted">Buffs: Dense Shields x1</text>
|
||||||
|
<text x="318" y="462" class="muted">Debuffs: Cost Up x1</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.4 KiB |
@@ -1,5 +1,6 @@
|
|||||||
import { readFileSync, writeFileSync } from 'node:fs'
|
import { readFileSync, writeFileSync } from 'node:fs'
|
||||||
import { DatabaseSync } from 'node:sqlite'
|
import { DatabaseSync } from 'node:sqlite'
|
||||||
|
import { catalogPayload } from '../server/catalog.mjs'
|
||||||
import { getProfile } from '../server/game-api.mjs'
|
import { getProfile } from '../server/game-api.mjs'
|
||||||
|
|
||||||
const database = new DatabaseSync(':memory:')
|
const database = new DatabaseSync(':memory:')
|
||||||
@@ -7,10 +8,14 @@ const database = new DatabaseSync(':memory:')
|
|||||||
try {
|
try {
|
||||||
database.exec(readFileSync('db/schema.sql', 'utf8'))
|
database.exec(readFileSync('db/schema.sql', 'utf8'))
|
||||||
database.exec(readFileSync('db/seed.sql', 'utf8'))
|
database.exec(readFileSync('db/seed.sql', 'utf8'))
|
||||||
const profile = getProfile(database, 1)
|
const catalog = catalogPayload(getProfile(database, 1))
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
'src/offline-starter-profile.json',
|
'src/offline-starter-profile.json',
|
||||||
`${JSON.stringify(profile, null, 2)}\n`,
|
`${JSON.stringify(catalog.profile, null, 2)}\n`,
|
||||||
|
)
|
||||||
|
writeFileSync(
|
||||||
|
'src/offline-catalog-meta.ts',
|
||||||
|
`export const bundledCatalogHash = '${catalog.hash}'\n`,
|
||||||
)
|
)
|
||||||
console.log('Offline starter profile exported from SQLite.')
|
console.log('Offline starter profile exported from SQLite.')
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { createHash } from 'node:crypto'
|
||||||
|
|
||||||
|
function normalizeRecipe(recipe) {
|
||||||
|
return {
|
||||||
|
...recipe,
|
||||||
|
components: recipe.components.map((component) => ({
|
||||||
|
...component,
|
||||||
|
owned: 0,
|
||||||
|
})),
|
||||||
|
canCraft: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDungeon(dungeon) {
|
||||||
|
return {
|
||||||
|
...dungeon,
|
||||||
|
completionCount: 0,
|
||||||
|
leaderboard: [],
|
||||||
|
leaderboards: {
|
||||||
|
part_1: [],
|
||||||
|
part_2: [],
|
||||||
|
part_3: [],
|
||||||
|
full_run: [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeCatalogProfile(profile) {
|
||||||
|
return {
|
||||||
|
...profile,
|
||||||
|
character: {
|
||||||
|
...profile.character,
|
||||||
|
id: 1,
|
||||||
|
name: 'Mira',
|
||||||
|
level: 1,
|
||||||
|
experience: 0,
|
||||||
|
talentPoints: 1,
|
||||||
|
currentLevelExperience: 0,
|
||||||
|
nextLevelExperience: 100,
|
||||||
|
},
|
||||||
|
abilitySlots: profile.abilitySlots,
|
||||||
|
allocatedTalentPoints: 0,
|
||||||
|
inventory: [],
|
||||||
|
completedDungeonParts: 0,
|
||||||
|
completedRaidPhases: 0,
|
||||||
|
gearStats: {
|
||||||
|
averageItemLevel: 0,
|
||||||
|
healingPower: 0,
|
||||||
|
maxResourceBonus: 0,
|
||||||
|
},
|
||||||
|
setBonuses: profile.setBonuses.map((bonus) => ({
|
||||||
|
...bonus,
|
||||||
|
equippedPieces: 0,
|
||||||
|
active: false,
|
||||||
|
})),
|
||||||
|
craftingRecipes: profile.craftingRecipes.map(normalizeRecipe),
|
||||||
|
dungeons: profile.dungeons.map(normalizeDungeon),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function catalogHash(profile) {
|
||||||
|
return createHash('sha256')
|
||||||
|
.update(JSON.stringify(profile))
|
||||||
|
.digest('hex')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function catalogPayload(profile) {
|
||||||
|
const normalized = normalizeCatalogProfile(profile)
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
hash: catalogHash(normalized),
|
||||||
|
profile: normalized,
|
||||||
|
}
|
||||||
|
}
|
||||||
+258
-2
@@ -9,6 +9,7 @@ import {
|
|||||||
import { isIP } from 'node:net'
|
import { isIP } from 'node:net'
|
||||||
import { extname, resolve, sep } from 'node:path'
|
import { extname, resolve, sep } from 'node:path'
|
||||||
import { DatabaseSync } from 'node:sqlite'
|
import { DatabaseSync } from 'node:sqlite'
|
||||||
|
import { catalogPayload } from './catalog.mjs'
|
||||||
|
|
||||||
const databasePath = fileURLToPath(new URL('../data/game.db', import.meta.url))
|
const databasePath = fileURLToPath(new URL('../data/game.db', import.meta.url))
|
||||||
const bossImageDirectory = fileURLToPath(new URL('../data/uploads/bosses/', import.meta.url))
|
const bossImageDirectory = fileURLToPath(new URL('../data/uploads/bosses/', import.meta.url))
|
||||||
@@ -26,6 +27,10 @@ const directCraftItemLevels = new Set([1, 10, 20, 25])
|
|||||||
const sessionCookieName = 'chronicle_session'
|
const sessionCookieName = 'chronicle_session'
|
||||||
const sessionLifetimeSeconds = 60 * 60 * 24 * 30
|
const sessionLifetimeSeconds = 60 * 60 * 24 * 30
|
||||||
const rateLimitBuckets = new Map()
|
const rateLimitBuckets = new Map()
|
||||||
|
const pvpQueue = new Map()
|
||||||
|
const pvpMatches = new Map()
|
||||||
|
const pvpQueueTtlMs = 15 * 1000
|
||||||
|
const pvpMatchTtlMs = 60 * 60 * 1000
|
||||||
|
|
||||||
function sendJson(response, status, body, headers = {}) {
|
function sendJson(response, status, body, headers = {}) {
|
||||||
response.statusCode = status
|
response.statusCode = status
|
||||||
@@ -348,10 +353,13 @@ function currentSession(database, request) {
|
|||||||
accounts.id AS accountId,
|
accounts.id AS accountId,
|
||||||
accounts.username,
|
accounts.username,
|
||||||
characters.id AS characterId,
|
characters.id AS characterId,
|
||||||
characters.class_id AS classId
|
characters.class_id AS classId,
|
||||||
|
characters.name AS characterName,
|
||||||
|
classes.name AS className
|
||||||
FROM sessions
|
FROM sessions
|
||||||
JOIN accounts ON accounts.id = sessions.account_id
|
JOIN accounts ON accounts.id = sessions.account_id
|
||||||
JOIN characters ON characters.id = sessions.active_character_id
|
JOIN characters ON characters.id = sessions.active_character_id
|
||||||
|
JOIN classes ON classes.id = characters.class_id
|
||||||
WHERE sessions.token_hash = ?
|
WHERE sessions.token_hash = ?
|
||||||
AND sessions.expires_at > CURRENT_TIMESTAMP
|
AND sessions.expires_at > CURRENT_TIMESTAMP
|
||||||
`).get(tokenHash(token)) ?? null
|
`).get(tokenHash(token)) ?? null
|
||||||
@@ -2537,6 +2545,212 @@ function saveProfile(database, characterId, accountId, payload) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cleanupPvpMemory(now = Date.now()) {
|
||||||
|
for (const [ticketId, ticket] of pvpQueue.entries()) {
|
||||||
|
if (now - ticket.updatedAt > pvpQueueTtlMs) pvpQueue.delete(ticketId)
|
||||||
|
}
|
||||||
|
for (const [matchId, match] of pvpMatches.entries()) {
|
||||||
|
if (now - match.updatedAt > pvpMatchTtlMs) pvpMatches.delete(matchId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validatePvpContentType(value) {
|
||||||
|
if (value !== 'dungeon' && value !== 'raid') {
|
||||||
|
throw new Error('The PvP content type is invalid.')
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
function validatePvpStartStage(value) {
|
||||||
|
const startStage = Number(value)
|
||||||
|
if (!Number.isInteger(startStage) || startStage < 1 || startStage > 1000) {
|
||||||
|
throw new Error('The PvP start stage is invalid.')
|
||||||
|
}
|
||||||
|
return startStage
|
||||||
|
}
|
||||||
|
|
||||||
|
function pvpPlayerInfo(session) {
|
||||||
|
return {
|
||||||
|
accountId: session.accountId,
|
||||||
|
characterId: session.characterId,
|
||||||
|
characterName: session.characterName,
|
||||||
|
className: session.className,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pvpSnapshot(match) {
|
||||||
|
return {
|
||||||
|
id: match.id,
|
||||||
|
contentType: match.contentType,
|
||||||
|
startStage: match.startStage,
|
||||||
|
createdAt: match.createdAt,
|
||||||
|
players: match.players,
|
||||||
|
states: match.states,
|
||||||
|
statuses: match.statuses,
|
||||||
|
progress: match.progress,
|
||||||
|
upgradeChoices: match.upgradeChoices,
|
||||||
|
updatedAt: match.updatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function joinPvpQueue(session, payload) {
|
||||||
|
const now = Date.now()
|
||||||
|
cleanupPvpMemory(now)
|
||||||
|
const contentType = validatePvpContentType(payload.contentType)
|
||||||
|
const startStage = validatePvpStartStage(payload.startStage)
|
||||||
|
const existingTicket = [...pvpQueue.values()].find((ticket) =>
|
||||||
|
ticket.accountId === session.accountId
|
||||||
|
&& ticket.characterId === session.characterId
|
||||||
|
&& ticket.contentType === contentType
|
||||||
|
&& ticket.startStage === startStage
|
||||||
|
)
|
||||||
|
if (existingTicket?.matchId) {
|
||||||
|
const match = pvpMatches.get(existingTicket.matchId)
|
||||||
|
if (match) {
|
||||||
|
const side = match.players.a.accountId === session.accountId ? 'a' : 'b'
|
||||||
|
return { ticketId: existingTicket.id, status: 'matched', side, match: pvpSnapshot(match) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const opponent = [...pvpQueue.values()]
|
||||||
|
.filter((ticket) =>
|
||||||
|
!ticket.matchId
|
||||||
|
&& ticket.contentType === contentType
|
||||||
|
&& ticket.startStage === startStage
|
||||||
|
&& ticket.accountId !== session.accountId
|
||||||
|
)
|
||||||
|
.sort((left, right) => left.createdAt - right.createdAt)[0]
|
||||||
|
const player = pvpPlayerInfo(session)
|
||||||
|
if (opponent) {
|
||||||
|
const matchId = randomBytes(12).toString('base64url')
|
||||||
|
const match = {
|
||||||
|
id: matchId,
|
||||||
|
contentType,
|
||||||
|
startStage,
|
||||||
|
createdAt: now,
|
||||||
|
players: {
|
||||||
|
a: { side: 'a', ...opponent.player },
|
||||||
|
b: { side: 'b', ...player },
|
||||||
|
},
|
||||||
|
states: {},
|
||||||
|
statuses: {},
|
||||||
|
progress: {},
|
||||||
|
upgradeChoices: {},
|
||||||
|
updatedAt: now,
|
||||||
|
}
|
||||||
|
pvpMatches.set(matchId, match)
|
||||||
|
opponent.matchId = matchId
|
||||||
|
opponent.updatedAt = now
|
||||||
|
const ticketId = randomBytes(12).toString('base64url')
|
||||||
|
pvpQueue.set(ticketId, {
|
||||||
|
id: ticketId,
|
||||||
|
accountId: session.accountId,
|
||||||
|
characterId: session.characterId,
|
||||||
|
contentType,
|
||||||
|
startStage,
|
||||||
|
player,
|
||||||
|
matchId,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
return { ticketId, status: 'matched', side: 'b', match: pvpSnapshot(match) }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingTicket) {
|
||||||
|
existingTicket.updatedAt = now
|
||||||
|
return { ticketId: existingTicket.id, status: 'waiting' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const ticketId = randomBytes(12).toString('base64url')
|
||||||
|
pvpQueue.set(ticketId, {
|
||||||
|
id: ticketId,
|
||||||
|
accountId: session.accountId,
|
||||||
|
characterId: session.characterId,
|
||||||
|
contentType,
|
||||||
|
startStage,
|
||||||
|
player,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
return { ticketId, status: 'waiting' }
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkPvpQueue(session, ticketId) {
|
||||||
|
cleanupPvpMemory()
|
||||||
|
const ticket = pvpQueue.get(ticketId)
|
||||||
|
if (!ticket || ticket.accountId !== session.accountId) {
|
||||||
|
const error = new Error('PvP queue ticket not found.')
|
||||||
|
error.status = 404
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
ticket.updatedAt = Date.now()
|
||||||
|
if (!ticket.matchId) return { ticketId, status: 'waiting' }
|
||||||
|
const match = pvpMatches.get(ticket.matchId)
|
||||||
|
if (!match) return { ticketId, status: 'waiting' }
|
||||||
|
const side = match.players.a.accountId === session.accountId ? 'a' : 'b'
|
||||||
|
return { ticketId, status: 'matched', side, match: pvpSnapshot(match) }
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelPvpQueue(session, ticketId) {
|
||||||
|
const ticket = pvpQueue.get(ticketId)
|
||||||
|
if (ticket && ticket.accountId === session.accountId && !ticket.matchId) {
|
||||||
|
pvpQueue.delete(ticketId)
|
||||||
|
}
|
||||||
|
return { ok: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
function requirePvpMatchForSession(session, matchId) {
|
||||||
|
cleanupPvpMemory()
|
||||||
|
const match = pvpMatches.get(matchId)
|
||||||
|
if (!match) {
|
||||||
|
const error = new Error('PvP match not found.')
|
||||||
|
error.status = 404
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
const side = match.players.a.accountId === session.accountId ? 'a'
|
||||||
|
: match.players.b.accountId === session.accountId ? 'b'
|
||||||
|
: null
|
||||||
|
if (!side) {
|
||||||
|
const error = new Error('That PvP match belongs to another account.')
|
||||||
|
error.status = 403
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
return { match, side }
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePvpMatchState(session, matchId, payload) {
|
||||||
|
const { match, side } = requirePvpMatchForSession(session, matchId)
|
||||||
|
const status = ['playing', 'upgrade-choice', 'won', 'lost'].includes(payload.status)
|
||||||
|
? payload.status
|
||||||
|
: 'playing'
|
||||||
|
const progress = {
|
||||||
|
stage: validatePvpStartStage(payload.stage),
|
||||||
|
encounterIndex: Math.max(0, Math.floor(Number(payload.encounterIndex) || 0)),
|
||||||
|
encountersCleared: Math.max(0, Math.floor(Number(payload.encountersCleared) || 0)),
|
||||||
|
enemyHealth: Math.max(0, Number(payload.enemyHealth) || 0),
|
||||||
|
alive: Boolean(payload.alive),
|
||||||
|
elapsedTicks: Math.max(0, Math.floor(Number(payload.elapsedTicks) || 0)),
|
||||||
|
}
|
||||||
|
match.states[side] = payload.state ?? null
|
||||||
|
match.statuses[side] = status
|
||||||
|
match.progress[side] = progress
|
||||||
|
match.updatedAt = Date.now()
|
||||||
|
return pvpSnapshot(match)
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitPvpUpgradeChoice(session, matchId, payload) {
|
||||||
|
const { match, side } = requirePvpMatchForSession(session, matchId)
|
||||||
|
const encounterIndex = Math.max(0, Math.floor(Number(payload.encounterIndex) || 0))
|
||||||
|
if (!match.upgradeChoices[side]) match.upgradeChoices[side] = {}
|
||||||
|
match.upgradeChoices[side][String(encounterIndex)] = {
|
||||||
|
encounterIndex,
|
||||||
|
buffId: String(payload.buffId ?? ''),
|
||||||
|
debuffId: String(payload.debuffId ?? ''),
|
||||||
|
}
|
||||||
|
match.updatedAt = Date.now()
|
||||||
|
return pvpSnapshot(match)
|
||||||
|
}
|
||||||
|
|
||||||
export function gameApiPlugin() {
|
export function gameApiPlugin() {
|
||||||
return {
|
return {
|
||||||
name: 'ashen-halls-game-api',
|
name: 'ashen-halls-game-api',
|
||||||
@@ -2687,7 +2901,7 @@ export async function handleApiRequest(request, response, next) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const ip = requestIp(request)
|
const ip = requestIp(request)
|
||||||
consumeRateLimit(`api:${ip}`, 240, 60 * 1000)
|
consumeRateLimit(`api:${ip}`, 900, 60 * 1000)
|
||||||
database.prepare(`
|
database.prepare(`
|
||||||
DELETE FROM sessions WHERE expires_at <= CURRENT_TIMESTAMP
|
DELETE FROM sessions WHERE expires_at <= CURRENT_TIMESTAMP
|
||||||
`).run()
|
`).run()
|
||||||
@@ -2696,6 +2910,11 @@ export async function handleApiRequest(request, response, next) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (request.url === '/api/catalog' && request.method === 'GET') {
|
||||||
|
sendJson(response, 200, catalogPayload(getProfile(database, 1)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const session = requireSession(database, request)
|
const session = requireSession(database, request)
|
||||||
|
|
||||||
if (request.url === '/api/profile/sync-save' && request.method === 'GET') {
|
if (request.url === '/api/profile/sync-save' && request.method === 'GET') {
|
||||||
@@ -2721,6 +2940,43 @@ export async function handleApiRequest(request, response, next) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (request.url === '/api/pvp/queue' && request.method === 'POST') {
|
||||||
|
const payload = await readJson(request)
|
||||||
|
sendJson(response, 200, joinPvpQueue(session, payload))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const pvpQueueTicket = request.url.match(/^\/api\/pvp\/queue\/([A-Za-z0-9_-]+)$/)
|
||||||
|
if (pvpQueueTicket && request.method === 'GET') {
|
||||||
|
sendJson(response, 200, checkPvpQueue(session, pvpQueueTicket[1]))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pvpQueueTicket && request.method === 'DELETE') {
|
||||||
|
sendJson(response, 200, cancelPvpQueue(session, pvpQueueTicket[1]))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const pvpMatchState = request.url.match(/^\/api\/pvp\/matches\/([A-Za-z0-9_-]+)\/state$/)
|
||||||
|
if (pvpMatchState && request.method === 'POST') {
|
||||||
|
const payload = await readJson(request, 128 * 1024)
|
||||||
|
sendJson(response, 200, updatePvpMatchState(session, pvpMatchState[1], payload))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const pvpUpgradeChoice = request.url.match(/^\/api\/pvp\/matches\/([A-Za-z0-9_-]+)\/upgrade-choice$/)
|
||||||
|
if (pvpUpgradeChoice && request.method === 'POST') {
|
||||||
|
const payload = await readJson(request)
|
||||||
|
sendJson(response, 200, submitPvpUpgradeChoice(session, pvpUpgradeChoice[1], payload))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const pvpMatch = request.url.match(/^\/api\/pvp\/matches\/([A-Za-z0-9_-]+)$/)
|
||||||
|
if (pvpMatch && request.method === 'GET') {
|
||||||
|
sendJson(response, 200, pvpSnapshot(requirePvpMatchForSession(session, pvpMatch[1]).match))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const dungeonCompletion = request.url.match(/^\/api\/dungeons\/(\d+)\/complete$/)
|
const dungeonCompletion = request.url.match(/^\/api\/dungeons\/(\d+)\/complete$/)
|
||||||
if (dungeonCompletion && request.method === 'POST') {
|
if (dungeonCompletion && request.method === 'POST') {
|
||||||
const payload = await readJson(request)
|
const payload = await readJson(request)
|
||||||
|
|||||||
+285
-23
@@ -621,6 +621,7 @@ textarea:focus-visible,
|
|||||||
min-height: 120px;
|
min-height: 120px;
|
||||||
outline: 2px solid #3a3944;
|
outline: 2px solid #3a3944;
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dual-top-member.selected {
|
.dual-top-member.selected {
|
||||||
@@ -773,7 +774,7 @@ textarea:focus-visible,
|
|||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
height: calc(100dvh - 20px);
|
height: calc(100dvh - 20px);
|
||||||
grid-template-rows: auto 1fr;
|
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -803,6 +804,84 @@ textarea:focus-visible,
|
|||||||
outline-color: var(--gold);
|
outline-color: var(--gold);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dual-top-spell-strip {
|
||||||
|
align-items: center;
|
||||||
|
background: var(--panel);
|
||||||
|
border: 3px solid #0c0d11;
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
grid-template-columns: repeat(6, 54px) minmax(180px, 1fr);
|
||||||
|
min-height: 64px;
|
||||||
|
outline: 2px solid var(--edge);
|
||||||
|
padding: 8px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dual-top-spell {
|
||||||
|
align-items: center;
|
||||||
|
background: #20232c;
|
||||||
|
border: 2px solid #08090c;
|
||||||
|
color: var(--ink);
|
||||||
|
display: flex;
|
||||||
|
height: 48px;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 0;
|
||||||
|
outline: 2px solid #4d4c58;
|
||||||
|
padding: 0;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dual-top-spell:not(:disabled) {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dual-top-spell:disabled {
|
||||||
|
opacity: 0.62;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dual-top-spell .spell-icon {
|
||||||
|
font-size: 20px;
|
||||||
|
height: 32px;
|
||||||
|
margin: 0;
|
||||||
|
width: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dual-top-spell > i {
|
||||||
|
background: rgba(0, 0, 0, 0.58);
|
||||||
|
bottom: 0;
|
||||||
|
display: block;
|
||||||
|
left: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dual-top-spell > small {
|
||||||
|
color: #fff4a8;
|
||||||
|
font-family: 'Press Start 2P', monospace;
|
||||||
|
font-size: 10px;
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dual-top-resource {
|
||||||
|
align-self: center;
|
||||||
|
color: #82bfff;
|
||||||
|
font-family: 'Press Start 2P', monospace;
|
||||||
|
font-size: 8px;
|
||||||
|
justify-self: end;
|
||||||
|
min-width: 220px;
|
||||||
|
width: min(280px, 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dual-top-resource strong {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dual-top-resource .bar {
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
.dual-bottom-display {
|
.dual-bottom-display {
|
||||||
background:
|
background:
|
||||||
linear-gradient(rgba(8, 9, 12, 0.91), rgba(8, 9, 12, 0.91)),
|
linear-gradient(rgba(8, 9, 12, 0.91), rgba(8, 9, 12, 0.91)),
|
||||||
@@ -815,6 +894,10 @@ textarea:focus-visible,
|
|||||||
padding: 10px;
|
padding: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pvp-opponent-bottom-display {
|
||||||
|
grid-template-rows: auto auto minmax(0, 1fr) auto;
|
||||||
|
}
|
||||||
|
|
||||||
.dual-controls-header,
|
.dual-controls-header,
|
||||||
.dual-controls-resource,
|
.dual-controls-resource,
|
||||||
.dual-controls-targets,
|
.dual-controls-targets,
|
||||||
@@ -837,6 +920,109 @@ textarea:focus-visible,
|
|||||||
font-size: clamp(14px, 2.2vw, 23px);
|
font-size: clamp(14px, 2.2vw, 23px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dual-controls-header small {
|
||||||
|
color: var(--muted);
|
||||||
|
display: block;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dual-opponent-progress,
|
||||||
|
.dual-opponent-effects {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 3px solid #0c0d11;
|
||||||
|
outline: 2px solid var(--edge);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dual-opponent-progress {
|
||||||
|
align-items: center;
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
grid-template-columns: minmax(130px, 0.45fr) minmax(0, 1fr);
|
||||||
|
padding: 10px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dual-opponent-progress strong {
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 19px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dual-opponent-progress .bar {
|
||||||
|
height: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dual-opponent-party-grid {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 3px solid #0c0d11;
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
min-height: 0;
|
||||||
|
outline: 2px solid var(--edge);
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dual-opponent-party-grid.raid {
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dual-opponent-member {
|
||||||
|
background: var(--panel-light);
|
||||||
|
border: 2px solid #0a0b0e;
|
||||||
|
min-width: 0;
|
||||||
|
outline: 2px solid #3a3944;
|
||||||
|
padding: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dual-opponent-member.dead {
|
||||||
|
filter: grayscale(1);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dual-opponent-member .member-header {
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dual-opponent-member .member-header strong {
|
||||||
|
font-size: 17px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dual-opponent-member .member-header small {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dual-opponent-member .bar {
|
||||||
|
height: 14px;
|
||||||
|
margin-top: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dual-opponent-party-grid.raid .dual-opponent-member {
|
||||||
|
padding: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dual-opponent-party-grid.raid .member-header strong {
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dual-opponent-party-grid.raid .member-header small {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dual-opponent-party-grid.raid .dual-opponent-member .bar {
|
||||||
|
height: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dual-opponent-effects {
|
||||||
|
color: var(--muted);
|
||||||
|
display: grid;
|
||||||
|
font-size: 14px;
|
||||||
|
gap: 8px;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.dual-controls-progress {
|
.dual-controls-progress {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-family: 'Press Start 2P', monospace;
|
font-family: 'Press Start 2P', monospace;
|
||||||
@@ -949,6 +1135,10 @@ textarea:focus-visible,
|
|||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dual-controls-header small {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
.dual-controls-progress {
|
.dual-controls-progress {
|
||||||
font-size: 6px;
|
font-size: 6px;
|
||||||
}
|
}
|
||||||
@@ -1022,6 +1212,67 @@ textarea:focus-visible,
|
|||||||
.dual-controls-spells .spell small {
|
.dual-controls-spells .spell small {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pvp-opponent-bottom-display {
|
||||||
|
grid-template-rows: auto auto minmax(0, 1fr) auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dual-opponent-progress {
|
||||||
|
border-width: 2px;
|
||||||
|
gap: 8px;
|
||||||
|
grid-template-columns: minmax(100px, 0.45fr) minmax(0, 1fr);
|
||||||
|
padding: 6px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dual-opponent-progress .eyebrow {
|
||||||
|
font-size: 6px;
|
||||||
|
margin-bottom: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dual-opponent-progress strong {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dual-opponent-progress .bar {
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dual-opponent-party-grid {
|
||||||
|
border-width: 2px;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dual-opponent-party-grid.raid {
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.dual-opponent-member {
|
||||||
|
padding: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dual-opponent-member .member-header strong {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dual-opponent-member .member-header small {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dual-opponent-member .bar {
|
||||||
|
height: 10px;
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dual-opponent-member .member-effects {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dual-opponent-effects {
|
||||||
|
border-width: 2px;
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.dual-bottom-waiting {
|
.dual-bottom-waiting {
|
||||||
@@ -1685,7 +1936,7 @@ h2 {
|
|||||||
.equipment-screen .crafting-panel,
|
.equipment-screen .crafting-panel,
|
||||||
.talent-screen .talent-tree,
|
.talent-screen .talent-tree,
|
||||||
.talent-screen .spell-effect-layout {
|
.talent-screen .spell-effect-layout {
|
||||||
flex: 1;
|
flex: 0 0 auto;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1712,7 +1963,8 @@ h2 {
|
|||||||
.customize-screen > .embedded-screen {
|
.customize-screen > .embedded-screen {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow-x: hidden;
|
||||||
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.customize-screen .loadout-editor {
|
.customize-screen .loadout-editor {
|
||||||
@@ -3519,6 +3771,7 @@ h2 {
|
|||||||
|
|
||||||
.spell-effect-layout {
|
.spell-effect-layout {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
flex: 0 0 auto;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
grid-template-columns: 220px minmax(0, 1fr);
|
grid-template-columns: 220px minmax(0, 1fr);
|
||||||
margin-top: 17px;
|
margin-top: 17px;
|
||||||
@@ -3537,6 +3790,7 @@ h2 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.effect-slots-panel {
|
.effect-slots-panel {
|
||||||
|
align-content: start;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
grid-auto-rows: minmax(76px, auto);
|
grid-auto-rows: minmax(76px, auto);
|
||||||
@@ -3640,12 +3894,12 @@ h2 {
|
|||||||
.effect-pool {
|
.effect-pool {
|
||||||
align-content: start;
|
align-content: start;
|
||||||
display: grid;
|
display: grid;
|
||||||
flex: 1;
|
flex: 0 0 auto;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
grid-template-rows: repeat(2, 62px);
|
grid-template-rows: repeat(2, 62px);
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
min-height: 0;
|
min-height: 134px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4703,7 +4957,7 @@ h2 {
|
|||||||
.customize-tabs {
|
.customize-tabs {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5252,22 +5506,6 @@ h2 {
|
|||||||
top: 0;
|
top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.member-health .health-text {
|
|
||||||
color: var(--ink);
|
|
||||||
font-family: 'Press Start 2P', monospace;
|
|
||||||
font-size: 7px;
|
|
||||||
font-style: normal;
|
|
||||||
left: 50%;
|
|
||||||
line-height: 1;
|
|
||||||
pointer-events: none;
|
|
||||||
position: absolute;
|
|
||||||
text-shadow: 1px 1px #08090c;
|
|
||||||
top: 50%;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
white-space: nowrap;
|
|
||||||
z-index: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.raid-party-grid .party-member {
|
.raid-party-grid .party-member {
|
||||||
min-height: 66px;
|
min-height: 66px;
|
||||||
padding: 7px;
|
padding: 7px;
|
||||||
@@ -5302,6 +5540,7 @@ h2 {
|
|||||||
inset: 0;
|
inset: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
z-index: 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.floating-heal {
|
.floating-heal {
|
||||||
@@ -5982,7 +6221,7 @@ h2 {
|
|||||||
.pvp-choice-columns {
|
.pvp-choice-columns {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6017,6 +6256,29 @@ h2 {
|
|||||||
margin-top: 8px !important;
|
margin-top: 8px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pvp-upgrade-header {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pvp-upgrade-header h2 {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pvp-upgrade-header > strong {
|
||||||
|
color: var(--gold);
|
||||||
|
font-family: 'Press Start 2P', monospace;
|
||||||
|
font-size: 16px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pvp-upgrade-header > strong.danger {
|
||||||
|
color: #ff8190;
|
||||||
|
}
|
||||||
|
|
||||||
.pvp-upgrade-dialog .pvp-choice-columns {
|
.pvp-upgrade-dialog .pvp-choice-columns {
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
|
|||||||
@@ -1375,6 +1375,7 @@ export function CombatScreen({
|
|||||||
encounterIndex,
|
encounterIndex,
|
||||||
encounterCount: encounters.length,
|
encounterCount: encounters.length,
|
||||||
party,
|
party,
|
||||||
|
floatingTexts,
|
||||||
partySize: dungeon.partySize,
|
partySize: dungeon.partySize,
|
||||||
selectedId,
|
selectedId,
|
||||||
log,
|
log,
|
||||||
@@ -1430,6 +1431,7 @@ export function CombatScreen({
|
|||||||
selectedId,
|
selectedId,
|
||||||
spells,
|
spells,
|
||||||
freeCastReady,
|
freeCastReady,
|
||||||
|
floatingTexts,
|
||||||
roguelikeUpgrades,
|
roguelikeUpgrades,
|
||||||
speedMultiplier,
|
speedMultiplier,
|
||||||
status,
|
status,
|
||||||
@@ -1521,7 +1523,6 @@ export function CombatScreen({
|
|||||||
<div className="bar member-health">
|
<div className="bar member-health">
|
||||||
<span style={{ width: `${(member.health / effectiveMaxHealth(member)) * 100}%` }} />
|
<span style={{ width: `${(member.health / effectiveMaxHealth(member)) * 100}%` }} />
|
||||||
{member.shield > 0 && <i style={{ width: `${(member.shield / effectiveMaxHealth(member)) * 100}%` }} />}
|
{member.shield > 0 && <i style={{ width: `${(member.shield / effectiveMaxHealth(member)) * 100}%` }} />}
|
||||||
<em className="health-text">{Math.ceil(member.health)} / {effectiveMaxHealth(member)}</em>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="floating-combat-texts" aria-hidden="true">
|
<div className="floating-combat-texts" aria-hidden="true">
|
||||||
{floatingTexts
|
{floatingTexts
|
||||||
@@ -1595,6 +1596,7 @@ export function CombatScreen({
|
|||||||
{dualScreenEnabled && (
|
{dualScreenEnabled && (
|
||||||
<DualScreenTopCombat
|
<DualScreenTopCombat
|
||||||
state={dualScreenState}
|
state={dualScreenState}
|
||||||
|
onCastSpell={castSpell}
|
||||||
onSelectTarget={setSelectedTargetId}
|
onSelectTarget={setSelectedTargetId}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -23,14 +23,24 @@ import {
|
|||||||
} from '../dualScreen'
|
} from '../dualScreen'
|
||||||
import {
|
import {
|
||||||
loadPvpRoguelikeCheckpoint,
|
loadPvpRoguelikeCheckpoint,
|
||||||
|
cancelPvpQueue,
|
||||||
|
checkPvpQueue,
|
||||||
|
joinPvpQueue,
|
||||||
|
loadPvpMatch,
|
||||||
|
publishPvpMatchState,
|
||||||
randomCpuDifficulty,
|
randomCpuDifficulty,
|
||||||
recordCpuPvpLeaderboard,
|
recordCpuPvpLeaderboard,
|
||||||
recordPvpRoguelikeCheckpoint,
|
recordPvpRoguelikeCheckpoint,
|
||||||
|
submitPvpUpgradeChoice,
|
||||||
type CpuDifficulty,
|
type CpuDifficulty,
|
||||||
|
type PvpMatchSnapshot,
|
||||||
|
type PvpMatchSide,
|
||||||
type PvpContentType,
|
type PvpContentType,
|
||||||
|
type PvpUpgradeChoicePayload,
|
||||||
} from '../pvpRoguelike'
|
} from '../pvpRoguelike'
|
||||||
|
|
||||||
const TICK_MS = 700
|
const TICK_MS = 700
|
||||||
|
const UPGRADE_CHOICE_SECONDS = 10
|
||||||
|
|
||||||
type BossMechanic =
|
type BossMechanic =
|
||||||
| 'party-pulse'
|
| 'party-pulse'
|
||||||
@@ -99,6 +109,14 @@ type PvpRunSummary = {
|
|||||||
loot: Array<NonNullable<DungeonReward['bonusItem']>>
|
loot: Array<NonNullable<DungeonReward['bonusItem']>>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type LivePvpMatch = {
|
||||||
|
id: string
|
||||||
|
side: PvpMatchSide
|
||||||
|
opponentSide: PvpMatchSide
|
||||||
|
opponentName: string
|
||||||
|
opponentClassName: string
|
||||||
|
}
|
||||||
|
|
||||||
const BOSS_MECHANICS: BossMechanic[] = [
|
const BOSS_MECHANICS: BossMechanic[] = [
|
||||||
'party-pulse',
|
'party-pulse',
|
||||||
'searing-mark',
|
'searing-mark',
|
||||||
@@ -449,6 +467,8 @@ export function PvPRoguelikeScreen({
|
|||||||
const [speedMultiplier, setSpeedMultiplier] = useState<1 | 2>(1)
|
const [speedMultiplier, setSpeedMultiplier] = useState<1 | 2>(1)
|
||||||
const [elapsedTicks, setElapsedTicks] = useState(0)
|
const [elapsedTicks, setElapsedTicks] = useState(0)
|
||||||
const [cpuDifficulty, setCpuDifficulty] = useState<CpuDifficulty | null>(null)
|
const [cpuDifficulty, setCpuDifficulty] = useState<CpuDifficulty | null>(null)
|
||||||
|
const [liveMatch, setLiveMatch] = useState<LivePvpMatch | null>(null)
|
||||||
|
const [liveUpgradePending, setLiveUpgradePending] = useState(false)
|
||||||
const [queueMessage, setQueueMessage] = useState('')
|
const [queueMessage, setQueueMessage] = useState('')
|
||||||
const [log, setLog] = useState<CombatLogEntry[]>([{ id: 1, text: 'Queueing opponent...', tone: 'system' }])
|
const [log, setLog] = useState<CombatLogEntry[]>([{ id: 1, text: 'Queueing opponent...', tone: 'system' }])
|
||||||
const [reward, setReward] = useState<DungeonReward | null>(null)
|
const [reward, setReward] = useState<DungeonReward | null>(null)
|
||||||
@@ -460,6 +480,7 @@ export function PvPRoguelikeScreen({
|
|||||||
const [playerDebuffChoices, setPlayerDebuffChoices] = useState<Array<Choice<OpponentDebuffId>>>([])
|
const [playerDebuffChoices, setPlayerDebuffChoices] = useState<Array<Choice<OpponentDebuffId>>>([])
|
||||||
const [selectedBuff, setSelectedBuff] = useState<Choice<SelfBuffId> | null>(null)
|
const [selectedBuff, setSelectedBuff] = useState<Choice<SelfBuffId> | null>(null)
|
||||||
const [selectedDebuff, setSelectedDebuff] = useState<Choice<OpponentDebuffId> | null>(null)
|
const [selectedDebuff, setSelectedDebuff] = useState<Choice<OpponentDebuffId> | null>(null)
|
||||||
|
const [upgradeTimeLeft, setUpgradeTimeLeft] = useState(UPGRADE_CHOICE_SECONDS)
|
||||||
const [encountersCleared, setEncountersCleared] = useState(0)
|
const [encountersCleared, setEncountersCleared] = useState(0)
|
||||||
const [paused, setPaused] = useState(false)
|
const [paused, setPaused] = useState(false)
|
||||||
const [targetGroup, setTargetGroup] = useState<0 | 1 | 2>(0)
|
const [targetGroup, setTargetGroup] = useState<0 | 1 | 2>(0)
|
||||||
@@ -471,6 +492,15 @@ export function PvPRoguelikeScreen({
|
|||||||
const cpuDefeatedRef = useRef(false)
|
const cpuDefeatedRef = useRef(false)
|
||||||
const playerClearedEncounterRef = useRef(-1)
|
const playerClearedEncounterRef = useRef(-1)
|
||||||
const queuedMatchRef = useRef(false)
|
const queuedMatchRef = useRef(false)
|
||||||
|
const upgradeChoiceEndsAtRef = useRef(0)
|
||||||
|
const autoSubmittedUpgradeRef = useRef(false)
|
||||||
|
const liveMatchRef = useRef<LivePvpMatch | null>(null)
|
||||||
|
const loggedOpponentDoneRef = useRef(false)
|
||||||
|
const pendingLiveUpgradeRef = useRef<{
|
||||||
|
encounterIndex: number
|
||||||
|
buff: Choice<SelfBuffId>
|
||||||
|
debuff: Choice<OpponentDebuffId>
|
||||||
|
} | null>(null)
|
||||||
const encounterPoolRef = useRef(encounterPool)
|
const encounterPoolRef = useRef(encounterPool)
|
||||||
const playerRef = useRef(playerSide)
|
const playerRef = useRef(playerSide)
|
||||||
const cpuRef = useRef(cpuSide)
|
const cpuRef = useRef(cpuSide)
|
||||||
@@ -484,6 +514,7 @@ export function PvPRoguelikeScreen({
|
|||||||
? Math.max(encountersCleared, encounterIndex + 1)
|
? Math.max(encountersCleared, encounterIndex + 1)
|
||||||
: encountersCleared
|
: encountersCleared
|
||||||
const cpuBehavior = cpuDifficulty ? CPU_BEHAVIOR[cpuDifficulty] : CPU_BEHAVIOR[1]
|
const cpuBehavior = cpuDifficulty ? CPU_BEHAVIOR[cpuDifficulty] : CPU_BEHAVIOR[1]
|
||||||
|
const opponentLabel = liveMatch ? liveMatch.opponentName : `CPU ${cpuDifficulty ?? 1}`
|
||||||
const activeSpellEffects = useMemo(
|
const activeSpellEffects = useMemo(
|
||||||
() => new Set(
|
() => new Set(
|
||||||
gameClass.talents
|
gameClass.talents
|
||||||
@@ -632,6 +663,9 @@ export function PvPRoguelikeScreen({
|
|||||||
setPlayerDebuffChoices([])
|
setPlayerDebuffChoices([])
|
||||||
setSelectedBuff(null)
|
setSelectedBuff(null)
|
||||||
setSelectedDebuff(null)
|
setSelectedDebuff(null)
|
||||||
|
setUpgradeTimeLeft(UPGRADE_CHOICE_SECONDS)
|
||||||
|
upgradeChoiceEndsAtRef.current = 0
|
||||||
|
autoSubmittedUpgradeRef.current = false
|
||||||
setEncountersCleared(0)
|
setEncountersCleared(0)
|
||||||
setPaused(false)
|
setPaused(false)
|
||||||
setTargetGroup(0)
|
setTargetGroup(0)
|
||||||
@@ -641,34 +675,159 @@ export function PvPRoguelikeScreen({
|
|||||||
setShowEndLog(false)
|
setShowEndLog(false)
|
||||||
setFloatingTexts([])
|
setFloatingTexts([])
|
||||||
setCpuDifficulty(null)
|
setCpuDifficulty(null)
|
||||||
|
setLiveMatch(null)
|
||||||
|
liveMatchRef.current = null
|
||||||
|
setLiveUpgradePending(false)
|
||||||
|
pendingLiveUpgradeRef.current = null
|
||||||
|
loggedOpponentDoneRef.current = false
|
||||||
recordedRunRef.current = false
|
recordedRunRef.current = false
|
||||||
rewardClaimedRef.current = false
|
rewardClaimedRef.current = false
|
||||||
cpuDefeatedRef.current = false
|
cpuDefeatedRef.current = false
|
||||||
|
const beginCpuMatch = (randomCpu: CpuDifficulty, message: string) => {
|
||||||
|
liveMatchRef.current = null
|
||||||
|
setLiveMatch(null)
|
||||||
|
setCpuDifficulty(randomCpu)
|
||||||
|
setQueueMessage(message)
|
||||||
|
setLog([{ id: 1, text: message, tone: 'system' }])
|
||||||
|
setStatus('playing')
|
||||||
|
addLog(`Stage ${matchStartStage} begins against CPU ${randomCpu}.`, 'system')
|
||||||
|
}
|
||||||
if (gameMode === 'offline') {
|
if (gameMode === 'offline') {
|
||||||
const randomCpu = randomCpuDifficulty()
|
const randomCpu = randomCpuDifficulty()
|
||||||
setQueueMessage(`Offline mode. CPU ${randomCpu} enters at stage ${matchStartStage}.`)
|
|
||||||
setCpuDifficulty(randomCpu)
|
|
||||||
setLog([{ id: 1, text: `Offline mode. CPU ${randomCpu} enters at stage ${matchStartStage}.`, tone: 'system' }])
|
|
||||||
const timer = window.setTimeout(() => {
|
const timer = window.setTimeout(() => {
|
||||||
setStatus('playing')
|
beginCpuMatch(randomCpu, `Offline mode. CPU ${randomCpu} enters at stage ${matchStartStage}.`)
|
||||||
addLog(`Stage ${matchStartStage} begins against CPU ${randomCpu}.`, 'system')
|
|
||||||
}, 500)
|
}, 500)
|
||||||
return () => window.clearTimeout(timer)
|
return () => window.clearTimeout(timer)
|
||||||
}
|
}
|
||||||
setQueueMessage(`Searching queue. Stage ${matchStartStage} start ready.`)
|
|
||||||
setLog([{ id: 1, text: `Searching queue. Stage ${matchStartStage} start ready.`, tone: 'system' }])
|
let cancelled = false
|
||||||
const timer = window.setTimeout(() => {
|
let ticketId = ''
|
||||||
const randomCpu = randomCpuDifficulty()
|
let pollTimer: number | undefined
|
||||||
setCpuDifficulty(randomCpu)
|
setQueueMessage(`Searching queue for 5s. Stage ${matchStartStage} start ready.`)
|
||||||
setQueueMessage(`No queued player found. CPU ${randomCpu} steps in.`)
|
setLog([{ id: 1, text: `Searching queue for 5s. Stage ${matchStartStage} start ready.`, tone: 'system' }])
|
||||||
|
const beginLiveMatch = (match: PvpMatchSnapshot<SideState>, side: PvpMatchSide) => {
|
||||||
|
if (cancelled) return
|
||||||
|
const opponentSide: PvpMatchSide = side === 'a' ? 'b' : 'a'
|
||||||
|
const opponent = match.players[opponentSide]
|
||||||
|
const nextLiveMatch = {
|
||||||
|
id: match.id,
|
||||||
|
side,
|
||||||
|
opponentSide,
|
||||||
|
opponentName: opponent.characterName,
|
||||||
|
opponentClassName: opponent.className,
|
||||||
|
}
|
||||||
|
liveMatchRef.current = nextLiveMatch
|
||||||
|
setLiveMatch(nextLiveMatch)
|
||||||
|
setCpuDifficulty(null)
|
||||||
|
const opponentBase = starterSide(
|
||||||
|
cpuPartyTemplate.map((member) => ({
|
||||||
|
...member,
|
||||||
|
name: member.id === 'mira' ? opponent.characterName : member.name,
|
||||||
|
})),
|
||||||
|
maxResource,
|
||||||
|
)
|
||||||
|
opponentBase.enemyHealth = firstEncounter.maxHealth
|
||||||
|
cpuRef.current = opponentBase
|
||||||
|
setCpuSide(opponentBase)
|
||||||
|
setQueueMessage(`${opponent.characterName} found. Match begins.`)
|
||||||
|
setLog([{ id: 1, text: `${opponent.characterName} found. Stage ${matchStartStage} begins.`, tone: 'system' }])
|
||||||
setStatus('playing')
|
setStatus('playing')
|
||||||
addLog(`No queued player found. CPU ${randomCpu} steps in at stage ${matchStartStage}.`, 'system')
|
}
|
||||||
}, 1400)
|
const fallbackTimer = window.setTimeout(() => {
|
||||||
return () => window.clearTimeout(timer)
|
if (cancelled || liveMatchRef.current) return
|
||||||
|
cancelled = true
|
||||||
|
if (ticketId) cancelPvpQueue(ticketId).catch(() => undefined)
|
||||||
|
const randomCpu = randomCpuDifficulty()
|
||||||
|
beginCpuMatch(randomCpu, `No queued player found after 5s. CPU ${randomCpu} steps in.`)
|
||||||
|
}, 5000)
|
||||||
|
const pollQueue = () => {
|
||||||
|
if (!ticketId || cancelled) return
|
||||||
|
checkPvpQueue<SideState>(ticketId)
|
||||||
|
.then((result) => {
|
||||||
|
if (cancelled) return
|
||||||
|
if (result.status === 'matched' && result.match && result.side) {
|
||||||
|
window.clearTimeout(fallbackTimer)
|
||||||
|
if (pollTimer) window.clearTimeout(pollTimer)
|
||||||
|
beginLiveMatch(result.match, result.side)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pollTimer = window.setTimeout(pollQueue, 500)
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) pollTimer = window.setTimeout(pollQueue, 700)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
joinPvpQueue<SideState>(contentType, matchStartStage)
|
||||||
|
.then((result) => {
|
||||||
|
if (cancelled) return
|
||||||
|
ticketId = result.ticketId
|
||||||
|
if (result.status === 'matched' && result.match && result.side) {
|
||||||
|
window.clearTimeout(fallbackTimer)
|
||||||
|
beginLiveMatch(result.match, result.side)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pollTimer = window.setTimeout(pollQueue, 500)
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (cancelled) return
|
||||||
|
window.clearTimeout(fallbackTimer)
|
||||||
|
cancelled = true
|
||||||
|
const randomCpu = randomCpuDifficulty()
|
||||||
|
beginCpuMatch(randomCpu, `PvP server unavailable. CPU ${randomCpu} steps in.`)
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
window.clearTimeout(fallbackTimer)
|
||||||
|
if (pollTimer) window.clearTimeout(pollTimer)
|
||||||
|
if (ticketId && !liveMatchRef.current) cancelPvpQueue(ticketId).catch(() => undefined)
|
||||||
|
}
|
||||||
}, [addLog, contentType, cpuPartyTemplate, gameMode, maxResource, partyTemplate, profile.character.id, setSelectedTargetId])
|
}, [addLog, contentType, cpuPartyTemplate, gameMode, maxResource, partyTemplate, profile.character.id, setSelectedTargetId])
|
||||||
|
|
||||||
useEffect(() => startMatch(), [startMatch])
|
useEffect(() => startMatch(), [startMatch])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!liveMatch || status === 'queueing') return
|
||||||
|
let stopped = false
|
||||||
|
const syncMatch = () => {
|
||||||
|
publishPvpMatchState<SideState>(liveMatch.id, {
|
||||||
|
state: playerRef.current,
|
||||||
|
status: status === 'upgrade-choice' ? 'upgrade-choice' : status,
|
||||||
|
stage,
|
||||||
|
encounterIndex,
|
||||||
|
encountersCleared,
|
||||||
|
enemyHealth: playerRef.current.enemyHealth,
|
||||||
|
alive: playerRef.current.party.some((member) => member.health > 0),
|
||||||
|
elapsedTicks,
|
||||||
|
})
|
||||||
|
.then((snapshot) => {
|
||||||
|
if (stopped) return
|
||||||
|
const opponentState = snapshot.states[liveMatch.opponentSide]
|
||||||
|
if (opponentState) {
|
||||||
|
cpuRef.current = opponentState
|
||||||
|
setCpuSide(opponentState)
|
||||||
|
}
|
||||||
|
const opponentStatus = snapshot.statuses[liveMatch.opponentSide]
|
||||||
|
if (opponentStatus === 'lost' && !loggedOpponentDoneRef.current) {
|
||||||
|
loggedOpponentDoneRef.current = true
|
||||||
|
cpuDefeatedRef.current = true
|
||||||
|
addLog(`${liveMatch.opponentName} fell. Finish the boss for XP.`, 'loot')
|
||||||
|
}
|
||||||
|
if (opponentStatus === 'won' && status !== 'won' && status !== 'lost') {
|
||||||
|
finishRoguelikeRun()
|
||||||
|
setStatus('lost')
|
||||||
|
addLog(`${liveMatch.opponentName} finished first.`, 'danger')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => undefined)
|
||||||
|
}
|
||||||
|
syncMatch()
|
||||||
|
const timer = window.setInterval(syncMatch, 700)
|
||||||
|
return () => {
|
||||||
|
stopped = true
|
||||||
|
window.clearInterval(timer)
|
||||||
|
}
|
||||||
|
}, [addLog, encounterIndex, encountersCleared, elapsedTicks, finishRoguelikeRun, liveMatch, stage, status])
|
||||||
|
|
||||||
const applySpell = useCallback((
|
const applySpell = useCallback((
|
||||||
current: SideState,
|
current: SideState,
|
||||||
setCurrent: React.Dispatch<React.SetStateAction<SideState>>,
|
setCurrent: React.Dispatch<React.SetStateAction<SideState>>,
|
||||||
@@ -981,6 +1140,9 @@ export function PvPRoguelikeScreen({
|
|||||||
}, [activeSpellEffects, addFloatingHeal, elapsedTicks, maxResource])
|
}, [activeSpellEffects, addFloatingHeal, elapsedTicks, maxResource])
|
||||||
|
|
||||||
const beginUpgradePhase = useCallback(() => {
|
const beginUpgradePhase = useCallback(() => {
|
||||||
|
upgradeChoiceEndsAtRef.current = Date.now() + UPGRADE_CHOICE_SECONDS * 1000
|
||||||
|
autoSubmittedUpgradeRef.current = false
|
||||||
|
setUpgradeTimeLeft(UPGRADE_CHOICE_SECONDS)
|
||||||
setPlayerBuffChoices(chooseRandom(selfBuffChoicesCatalog, 3))
|
setPlayerBuffChoices(chooseRandom(selfBuffChoicesCatalog, 3))
|
||||||
setPlayerDebuffChoices(chooseRandom(opponentDebuffChoicesCatalog, 3))
|
setPlayerDebuffChoices(chooseRandom(opponentDebuffChoicesCatalog, 3))
|
||||||
setSelectedBuff(null)
|
setSelectedBuff(null)
|
||||||
@@ -992,9 +1154,9 @@ export function PvPRoguelikeScreen({
|
|||||||
if (status !== 'playing' || paused || !encounter) return
|
if (status !== 'playing' || paused || !encounter) return
|
||||||
const timer = window.setInterval(() => {
|
const timer = window.setInterval(() => {
|
||||||
setElapsedTicks((value) => value + 1)
|
setElapsedTicks((value) => value + 1)
|
||||||
cpuTakeTurn()
|
if (!liveMatch) cpuTakeTurn()
|
||||||
const nextPlayer = advanceSide(playerRef.current, 'player', encounter)
|
const nextPlayer = advanceSide(playerRef.current, 'player', encounter)
|
||||||
const nextCpu = advanceSide(cpuRef.current, 'cpu', encounter)
|
const nextCpu = liveMatch ? cpuRef.current : advanceSide(cpuRef.current, 'cpu', encounter)
|
||||||
if (nextPlayer.enemyHealth <= 0 && playerClearedEncounterRef.current !== encounterIndex) {
|
if (nextPlayer.enemyHealth <= 0 && playerClearedEncounterRef.current !== encounterIndex) {
|
||||||
playerClearedEncounterRef.current = encounterIndex
|
playerClearedEncounterRef.current = encounterIndex
|
||||||
setEncountersCleared((value) => value + 1)
|
setEncountersCleared((value) => value + 1)
|
||||||
@@ -1024,7 +1186,7 @@ export function PvPRoguelikeScreen({
|
|||||||
addLog('Your party fell first.', 'danger')
|
addLog('Your party fell first.', 'danger')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!nextCpuAlive && !cpuDefeatedRef.current) {
|
if (!liveMatch && !nextCpuAlive && !cpuDefeatedRef.current) {
|
||||||
cpuDefeatedRef.current = true
|
cpuDefeatedRef.current = true
|
||||||
addLog(`CPU ${cpuDifficulty ?? 1} fell. Finish the boss for XP.`, 'loot')
|
addLog(`CPU ${cpuDifficulty ?? 1} fell. Finish the boss for XP.`, 'loot')
|
||||||
}
|
}
|
||||||
@@ -1032,7 +1194,7 @@ export function PvPRoguelikeScreen({
|
|||||||
if (encounter.isBoss && cpuDefeatedRef.current) {
|
if (encounter.isBoss && cpuDefeatedRef.current) {
|
||||||
finishRoguelikeRun()
|
finishRoguelikeRun()
|
||||||
setStatus('won')
|
setStatus('won')
|
||||||
addLog('CPU defeated. Match complete.', 'loot')
|
addLog(`${liveMatch ? liveMatch.opponentName : `CPU ${cpuDifficulty ?? 1}`} defeated. Match complete.`, 'loot')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
addLog(`${encounter.enemyName} cleared. Choose your next edge.`, 'loot')
|
addLog(`${encounter.enemyName} cleared. Choose your next edge.`, 'loot')
|
||||||
@@ -1040,7 +1202,7 @@ export function PvPRoguelikeScreen({
|
|||||||
}
|
}
|
||||||
}, TICK_MS / speedMultiplier)
|
}, TICK_MS / speedMultiplier)
|
||||||
return () => window.clearInterval(timer)
|
return () => window.clearInterval(timer)
|
||||||
}, [addLog, advanceSide, awardBossReward, beginUpgradePhase, checkpointStage, contentType, cpuDifficulty, cpuTakeTurn, encounter, encounterIndex, encountersCleared, finishRoguelikeRun, paused, profile.character.id, speedMultiplier, stage, status])
|
}, [addLog, advanceSide, awardBossReward, beginUpgradePhase, checkpointStage, contentType, cpuDifficulty, cpuTakeTurn, encounter, encounterIndex, encountersCleared, finishRoguelikeRun, liveMatch, paused, profile.character.id, speedMultiplier, stage, status])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if ((status !== 'won' && status !== 'lost') || recordedRunRef.current || !cpuDifficulty) return
|
if ((status !== 'won' && status !== 'lost') || recordedRunRef.current || !cpuDifficulty) return
|
||||||
@@ -1066,8 +1228,117 @@ export function PvPRoguelikeScreen({
|
|||||||
window.requestAnimationFrame(() => focusFirstControl())
|
window.requestAnimationFrame(() => focusFirstControl())
|
||||||
}, [paused])
|
}, [paused])
|
||||||
|
|
||||||
const confirmUpgradeChoices = useCallback(() => {
|
const confirmUpgradeChoices = useCallback((
|
||||||
if (!selectedBuff || !selectedDebuff || !cpuDifficulty) return
|
forcedBuff?: Choice<SelfBuffId>,
|
||||||
|
forcedDebuff?: Choice<OpponentDebuffId>,
|
||||||
|
) => {
|
||||||
|
const chosenBuff = forcedBuff ?? selectedBuff
|
||||||
|
const chosenDebuff = forcedDebuff ?? selectedDebuff
|
||||||
|
if (!chosenBuff || !chosenDebuff) return
|
||||||
|
if (liveMatch) {
|
||||||
|
const submittedBuff = chosenBuff
|
||||||
|
const submittedDebuff = chosenDebuff
|
||||||
|
const clearedEncounterIndex = encounterIndex
|
||||||
|
const applyLiveUpgrade = (opponentChoice: PvpUpgradeChoicePayload) => {
|
||||||
|
let nextPlayer = {
|
||||||
|
...playerRef.current,
|
||||||
|
buffs: [...playerRef.current.buffs, submittedBuff.id],
|
||||||
|
}
|
||||||
|
if (opponentChoice.debuffId === 'opp-purge-random-buff') {
|
||||||
|
nextPlayer = removeRandomBuff(nextPlayer)
|
||||||
|
} else if (opponentDebuffChoicesCatalog.some((choice) => choice.id === opponentChoice.debuffId)) {
|
||||||
|
nextPlayer = { ...nextPlayer, debuffs: [...nextPlayer.debuffs, opponentChoice.debuffId as OpponentDebuffId] }
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearedBoss = encounter.isBoss
|
||||||
|
if (clearedBoss && cpuDefeatedRef.current) {
|
||||||
|
finishRoguelikeRun()
|
||||||
|
setStatus('won')
|
||||||
|
setLiveUpgradePending(false)
|
||||||
|
addLog(`${liveMatch.opponentName} defeated. Match complete.`, 'loot')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const nextStage = clearedBoss ? stage + 1 : stage
|
||||||
|
const nextSegment = clearedBoss ? buildEncounterSegment(encounterPool, nextStage, contentType) : []
|
||||||
|
const nextEncounter = clearedBoss ? nextSegment[0] : encounters[encounterIndex + 1]
|
||||||
|
if (!nextEncounter) {
|
||||||
|
finishRoguelikeRun()
|
||||||
|
setStatus('won')
|
||||||
|
setLiveUpgradePending(false)
|
||||||
|
addLog('No further encounters remain.', 'loot')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
nextPlayer = {
|
||||||
|
...nextPlayer,
|
||||||
|
party: nextPlayer.party.map((member) => ({
|
||||||
|
...member,
|
||||||
|
health: member.health <= 0 ? 0 : clamp(member.health + Math.round(member.maxHealth * 0.3), 0, member.maxHealth),
|
||||||
|
debuff: undefined,
|
||||||
|
debuffTicks: undefined,
|
||||||
|
poisonStacks: undefined,
|
||||||
|
maxHealthPenaltyTicks: undefined,
|
||||||
|
healingReductionTicks: undefined,
|
||||||
|
})),
|
||||||
|
resource: clamp(nextPlayer.resource + Math.round(maxResource * 0.25), 0, maxResource),
|
||||||
|
cooldowns: {},
|
||||||
|
enemyHealth: nextEncounter.maxHealth,
|
||||||
|
}
|
||||||
|
if (clearedBoss) {
|
||||||
|
setStage(nextStage)
|
||||||
|
setEncounters((current) => [...current, ...nextSegment])
|
||||||
|
}
|
||||||
|
setEncounterIndex((value) => value + 1)
|
||||||
|
setPlayerSide(nextPlayer)
|
||||||
|
playerRef.current = nextPlayer
|
||||||
|
setElapsedTicks(0)
|
||||||
|
setLiveUpgradePending(false)
|
||||||
|
pendingLiveUpgradeRef.current = null
|
||||||
|
setStatus('playing')
|
||||||
|
const opponentDebuff = opponentDebuffChoicesCatalog.find((choice) => choice.id === opponentChoice.debuffId)
|
||||||
|
addLog(
|
||||||
|
`You chose ${submittedBuff.name} and ${submittedDebuff.name}. ${liveMatch.opponentName} chose ${opponentDebuff?.name ?? 'an opponent debuff'}.`,
|
||||||
|
'system',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
setLiveUpgradePending(true)
|
||||||
|
pendingLiveUpgradeRef.current = {
|
||||||
|
encounterIndex: clearedEncounterIndex,
|
||||||
|
buff: submittedBuff,
|
||||||
|
debuff: submittedDebuff,
|
||||||
|
}
|
||||||
|
addLog(`Waiting for ${liveMatch.opponentName} to choose.`, 'system')
|
||||||
|
submitPvpUpgradeChoice(liveMatch.id, {
|
||||||
|
encounterIndex: clearedEncounterIndex,
|
||||||
|
buffId: submittedBuff.id,
|
||||||
|
debuffId: submittedDebuff.id,
|
||||||
|
}).catch((reason: unknown) => {
|
||||||
|
setLiveUpgradePending(false)
|
||||||
|
addLog(reason instanceof Error ? reason.message : 'Unable to submit PvP upgrade choice.', 'danger')
|
||||||
|
})
|
||||||
|
let attempts = 0
|
||||||
|
const waitForOpponent = () => {
|
||||||
|
attempts += 1
|
||||||
|
loadPvpMatch<SideState>(liveMatch.id)
|
||||||
|
.then((snapshot) => {
|
||||||
|
const opponentChoice = snapshot.upgradeChoices[liveMatch.opponentSide]?.[String(clearedEncounterIndex)]
|
||||||
|
if (opponentChoice) {
|
||||||
|
applyLiveUpgrade(opponentChoice)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (attempts < 120 && pendingLiveUpgradeRef.current) {
|
||||||
|
window.setTimeout(waitForOpponent, 500)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (attempts < 120 && pendingLiveUpgradeRef.current) {
|
||||||
|
window.setTimeout(waitForOpponent, 700)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
window.setTimeout(waitForOpponent, 250)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!cpuDifficulty) return
|
||||||
const cpuBuffChoices = chooseRandom(selfBuffChoicesCatalog, 3)
|
const cpuBuffChoices = chooseRandom(selfBuffChoicesCatalog, 3)
|
||||||
const cpuDebuffChoices = chooseRandom(opponentDebuffChoicesCatalog, 3)
|
const cpuDebuffChoices = chooseRandom(opponentDebuffChoicesCatalog, 3)
|
||||||
const cpuBuff = selectCpuChoice(cpuBuffChoices, cpuDifficulty, (choice) => scoreSelfBuff(choice, starterSpells))
|
const cpuBuff = selectCpuChoice(cpuBuffChoices, cpuDifficulty, (choice) => scoreSelfBuff(choice, starterSpells))
|
||||||
@@ -1075,17 +1346,17 @@ export function PvPRoguelikeScreen({
|
|||||||
|
|
||||||
let nextPlayer = {
|
let nextPlayer = {
|
||||||
...playerRef.current,
|
...playerRef.current,
|
||||||
buffs: [...playerRef.current.buffs, selectedBuff.id],
|
buffs: [...playerRef.current.buffs, chosenBuff.id],
|
||||||
}
|
}
|
||||||
let nextCpu = {
|
let nextCpu = {
|
||||||
...cpuRef.current,
|
...cpuRef.current,
|
||||||
buffs: [...cpuRef.current.buffs, cpuBuff.id],
|
buffs: [...cpuRef.current.buffs, cpuBuff.id],
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedDebuff.id === 'opp-purge-random-buff') {
|
if (chosenDebuff.id === 'opp-purge-random-buff') {
|
||||||
nextCpu = removeRandomBuff(nextCpu)
|
nextCpu = removeRandomBuff(nextCpu)
|
||||||
} else {
|
} else {
|
||||||
nextCpu = { ...nextCpu, debuffs: [...nextCpu.debuffs, selectedDebuff.id] }
|
nextCpu = { ...nextCpu, debuffs: [...nextCpu.debuffs, chosenDebuff.id] }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cpuDebuff.id === 'opp-purge-random-buff') {
|
if (cpuDebuff.id === 'opp-purge-random-buff') {
|
||||||
@@ -1153,8 +1424,41 @@ export function PvPRoguelikeScreen({
|
|||||||
cpuRef.current = nextCpu
|
cpuRef.current = nextCpu
|
||||||
setElapsedTicks(0)
|
setElapsedTicks(0)
|
||||||
setStatus('playing')
|
setStatus('playing')
|
||||||
addLog(`You chose ${selectedBuff.name} and ${selectedDebuff.name}. CPU ${cpuDifficulty} chose ${cpuBuff.name} and ${cpuDebuff.name}.`, 'system')
|
addLog(`You chose ${chosenBuff.name} and ${chosenDebuff.name}. CPU ${cpuDifficulty} chose ${cpuBuff.name} and ${cpuDebuff.name}.`, 'system')
|
||||||
}, [addLog, contentType, cpuDifficulty, encounter, encounterIndex, encounterPool, encounters, finishRoguelikeRun, maxResource, opponentDebuffChoicesCatalog, selectedBuff, selectedDebuff, selfBuffChoicesCatalog, stage, starterSpells])
|
}, [addLog, contentType, cpuDifficulty, encounter, encounterIndex, encounterPool, encounters, finishRoguelikeRun, liveMatch, maxResource, opponentDebuffChoicesCatalog, selectedBuff, selectedDebuff, selfBuffChoicesCatalog, stage, starterSpells])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (status !== 'upgrade-choice' || liveUpgradePending) return
|
||||||
|
if (upgradeChoiceEndsAtRef.current <= 0) {
|
||||||
|
upgradeChoiceEndsAtRef.current = Date.now() + UPGRADE_CHOICE_SECONDS * 1000
|
||||||
|
}
|
||||||
|
const updateTimer = () => {
|
||||||
|
const remaining = Math.max(0, (upgradeChoiceEndsAtRef.current - Date.now()) / 1000)
|
||||||
|
setUpgradeTimeLeft(remaining)
|
||||||
|
if (remaining > 0 || autoSubmittedUpgradeRef.current) return
|
||||||
|
autoSubmittedUpgradeRef.current = true
|
||||||
|
const autoBuff = selectedBuff ?? playerBuffChoices[Math.floor(Math.random() * playerBuffChoices.length)]
|
||||||
|
const autoDebuff = selectedDebuff ?? playerDebuffChoices[Math.floor(Math.random() * playerDebuffChoices.length)]
|
||||||
|
if (autoBuff) setSelectedBuff(autoBuff)
|
||||||
|
if (autoDebuff) setSelectedDebuff(autoDebuff)
|
||||||
|
if (autoBuff && autoDebuff) {
|
||||||
|
addLog('Upgrade timer expired. Random choices selected.', 'system')
|
||||||
|
confirmUpgradeChoices(autoBuff, autoDebuff)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateTimer()
|
||||||
|
const timer = window.setInterval(updateTimer, 100)
|
||||||
|
return () => window.clearInterval(timer)
|
||||||
|
}, [
|
||||||
|
addLog,
|
||||||
|
confirmUpgradeChoices,
|
||||||
|
liveUpgradePending,
|
||||||
|
playerBuffChoices,
|
||||||
|
playerDebuffChoices,
|
||||||
|
selectedBuff,
|
||||||
|
selectedDebuff,
|
||||||
|
status,
|
||||||
|
])
|
||||||
|
|
||||||
useGameAction((action) => {
|
useGameAction((action) => {
|
||||||
if (action === 'toggleSpeed') {
|
if (action === 'toggleSpeed') {
|
||||||
@@ -1212,6 +1516,16 @@ export function PvPRoguelikeScreen({
|
|||||||
encounterIndex,
|
encounterIndex,
|
||||||
encounterCount: encounters.length,
|
encounterCount: encounters.length,
|
||||||
party: playerSide.party,
|
party: playerSide.party,
|
||||||
|
opponentName: opponentLabel,
|
||||||
|
opponentClassName: liveMatch?.opponentClassName ?? (cpuDifficulty ? `CPU ${cpuDifficulty}` : 'CPU'),
|
||||||
|
opponentParty: cpuSide.party,
|
||||||
|
opponentResource: cpuSide.resource,
|
||||||
|
opponentEnemyHealth: cpuSide.enemyHealth,
|
||||||
|
opponentBuffSummary: cpuSide.buffs.length > 0 ? summarizeStacks(cpuSide.buffs, selfBuffChoicesCatalog) : 'none',
|
||||||
|
opponentDebuffSummary: cpuSide.debuffs.length > 0 ? summarizeStacks(cpuSide.debuffs, opponentDebuffChoicesCatalog) : 'none',
|
||||||
|
floatingTexts: floatingTexts
|
||||||
|
.filter((entry) => entry.side === 'player')
|
||||||
|
.map(({ id, memberId, value }) => ({ id, memberId, value })),
|
||||||
partySize: playerSide.party.length,
|
partySize: playerSide.party.length,
|
||||||
selectedId,
|
selectedId,
|
||||||
log,
|
log,
|
||||||
@@ -1236,6 +1550,12 @@ export function PvPRoguelikeScreen({
|
|||||||
}), [
|
}), [
|
||||||
bindings,
|
bindings,
|
||||||
controllerIconStyle,
|
controllerIconStyle,
|
||||||
|
cpuDifficulty,
|
||||||
|
cpuSide.buffs,
|
||||||
|
cpuSide.debuffs,
|
||||||
|
cpuSide.enemyHealth,
|
||||||
|
cpuSide.party,
|
||||||
|
cpuSide.resource,
|
||||||
directPartyTargeting,
|
directPartyTargeting,
|
||||||
encounter.description,
|
encounter.description,
|
||||||
encounter.enemyName,
|
encounter.enemyName,
|
||||||
@@ -1243,10 +1563,15 @@ export function PvPRoguelikeScreen({
|
|||||||
encounter.maxHealth,
|
encounter.maxHealth,
|
||||||
encounterIndex,
|
encounterIndex,
|
||||||
encounters.length,
|
encounters.length,
|
||||||
|
floatingTexts,
|
||||||
gameClass.resourceName,
|
gameClass.resourceName,
|
||||||
lastDevice,
|
lastDevice,
|
||||||
|
liveMatch?.opponentClassName,
|
||||||
log,
|
log,
|
||||||
maxResource,
|
maxResource,
|
||||||
|
opponentDebuffChoicesCatalog,
|
||||||
|
opponentLabel,
|
||||||
|
selfBuffChoicesCatalog,
|
||||||
paused,
|
paused,
|
||||||
playerAlive,
|
playerAlive,
|
||||||
playerSide.buffs,
|
playerSide.buffs,
|
||||||
@@ -1281,6 +1606,7 @@ export function PvPRoguelikeScreen({
|
|||||||
{dualScreenEnabled && status !== 'queueing' && (
|
{dualScreenEnabled && status !== 'queueing' && (
|
||||||
<DualScreenTopCombat
|
<DualScreenTopCombat
|
||||||
state={dualScreenState}
|
state={dualScreenState}
|
||||||
|
onCastSpell={castPlayerSpell}
|
||||||
onSelectTarget={setSelectedTargetId}
|
onSelectTarget={setSelectedTargetId}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -1317,7 +1643,6 @@ export function PvPRoguelikeScreen({
|
|||||||
<div className="bar member-health">
|
<div className="bar member-health">
|
||||||
<span style={{ width: `${(member.health / effectiveMaxHealth(member)) * 100}%` }} />
|
<span style={{ width: `${(member.health / effectiveMaxHealth(member)) * 100}%` }} />
|
||||||
{member.shield > 0 && <i style={{ width: `${(member.shield / effectiveMaxHealth(member)) * 100}%` }} />}
|
{member.shield > 0 && <i style={{ width: `${(member.shield / effectiveMaxHealth(member)) * 100}%` }} />}
|
||||||
<em className="health-text">{Math.floor(member.health)} / {effectiveMaxHealth(member)}</em>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="floating-combat-texts" aria-hidden="true">
|
<div className="floating-combat-texts" aria-hidden="true">
|
||||||
{floatingTexts
|
{floatingTexts
|
||||||
@@ -1357,7 +1682,7 @@ export function PvPRoguelikeScreen({
|
|||||||
<small>{Math.max(0, Math.floor(playerSide.enemyHealth))} / {encounter.maxHealth}</small>
|
<small>{Math.max(0, Math.floor(playerSide.enemyHealth))} / {encounter.maxHealth}</small>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<strong>CPU clear</strong>
|
<strong>{liveMatch ? `${liveMatch.opponentName} clear` : 'CPU clear'}</strong>
|
||||||
<div className="bar enemy-health boss-bar">
|
<div className="bar enemy-health boss-bar">
|
||||||
<span style={{ width: `${(cpuSide.enemyHealth / encounter.maxHealth) * 100}%` }} />
|
<span style={{ width: `${(cpuSide.enemyHealth / encounter.maxHealth) * 100}%` }} />
|
||||||
</div>
|
</div>
|
||||||
@@ -1391,14 +1716,16 @@ export function PvPRoguelikeScreen({
|
|||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<p className="roguelike-upgrade-list">CPU {cpuDifficulty} | Encounters cleared: {encountersCleared}</p>
|
<p className="roguelike-upgrade-list">
|
||||||
|
{liveMatch ? `${liveMatch.opponentName} (${liveMatch.opponentClassName})` : `CPU ${cpuDifficulty}`} | Encounters cleared: {encountersCleared}
|
||||||
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="combat-panel pvp-side">
|
<section className="combat-panel pvp-side">
|
||||||
<div className="encounter-header">
|
<div className="encounter-header">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">Opponent</p>
|
<p className="eyebrow">Opponent</p>
|
||||||
<h2>CPU {cpuDifficulty}</h2>
|
<h2>{opponentLabel}</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="resource-row pvp-resource-row">
|
<div className="resource-row pvp-resource-row">
|
||||||
<div className="pvp-resource-wrap">
|
<div className="pvp-resource-wrap">
|
||||||
@@ -1418,7 +1745,6 @@ export function PvPRoguelikeScreen({
|
|||||||
<div className="bar member-health">
|
<div className="bar member-health">
|
||||||
<span style={{ width: `${(member.health / effectiveMaxHealth(member)) * 100}%` }} />
|
<span style={{ width: `${(member.health / effectiveMaxHealth(member)) * 100}%` }} />
|
||||||
{member.shield > 0 && <i style={{ width: `${(member.shield / effectiveMaxHealth(member)) * 100}%` }} />}
|
{member.shield > 0 && <i style={{ width: `${(member.shield / effectiveMaxHealth(member)) * 100}%` }} />}
|
||||||
<em className="health-text">{Math.floor(member.health)} / {effectiveMaxHealth(member)}</em>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="floating-combat-texts" aria-hidden="true">
|
<div className="floating-combat-texts" aria-hidden="true">
|
||||||
{floatingTexts
|
{floatingTexts
|
||||||
@@ -1446,6 +1772,13 @@ export function PvPRoguelikeScreen({
|
|||||||
{status === 'upgrade-choice' && (
|
{status === 'upgrade-choice' && (
|
||||||
<div className="result-screen">
|
<div className="result-screen">
|
||||||
<div className="pvp-upgrade-dialog">
|
<div className="pvp-upgrade-dialog">
|
||||||
|
<div className="pvp-upgrade-header">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Choose Edge</p>
|
||||||
|
<h2>{encounter.isBoss ? `Stage ${stage} Boss Cleared` : `${encounter.enemyName} Cleared`}</h2>
|
||||||
|
</div>
|
||||||
|
<strong className={upgradeTimeLeft <= 3 ? 'danger' : ''}>{upgradeTimeLeft.toFixed(1)}s</strong>
|
||||||
|
</div>
|
||||||
<div className="pvp-choice-columns">
|
<div className="pvp-choice-columns">
|
||||||
<div>
|
<div>
|
||||||
<strong>Self Buff</strong>
|
<strong>Self Buff</strong>
|
||||||
@@ -1480,8 +1813,9 @@ export function PvPRoguelikeScreen({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button className="secondary-result-button" disabled={!selectedBuff || !selectedDebuff} onClick={confirmUpgradeChoices} type="button">
|
{liveUpgradePending && <p>Waiting for opponent choice...</p>}
|
||||||
Continue
|
<button className="secondary-result-button" disabled={!selectedBuff || !selectedDebuff || liveUpgradePending} onClick={() => confirmUpgradeChoices()} type="button">
|
||||||
|
{liveUpgradePending ? 'Waiting' : 'Continue'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1502,7 +1836,7 @@ export function PvPRoguelikeScreen({
|
|||||||
<div className="result-screen">
|
<div className="result-screen">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">{status === 'won' ? 'Victory' : 'Defeat'}</p>
|
<p className="eyebrow">{status === 'won' ? 'Victory' : 'Defeat'}</p>
|
||||||
<h2>{status === 'won' ? `CPU ${cpuDifficulty} Falls` : `CPU ${cpuDifficulty} Wins`}</h2>
|
<h2>{status === 'won' ? `${opponentLabel} Falls` : `${opponentLabel} Wins`}</h2>
|
||||||
<p>{finalEncountersCleared} encounters cleared.</p>
|
<p>{finalEncountersCleared} encounters cleared.</p>
|
||||||
<div className="reward-summary">
|
<div className="reward-summary">
|
||||||
<p>{runSummary.bossesKilled} bosses killed.</p>
|
<p>{runSummary.bossesKilled} bosses killed.</p>
|
||||||
|
|||||||
+104
-5
@@ -39,6 +39,18 @@ export type DualScreenCombatState = {
|
|||||||
encounterIndex: number
|
encounterIndex: number
|
||||||
encounterCount: number
|
encounterCount: number
|
||||||
party: PartyMember[]
|
party: PartyMember[]
|
||||||
|
opponentName?: string
|
||||||
|
opponentClassName?: string
|
||||||
|
opponentParty?: PartyMember[]
|
||||||
|
opponentResource?: number
|
||||||
|
opponentEnemyHealth?: number
|
||||||
|
opponentBuffSummary?: string
|
||||||
|
opponentDebuffSummary?: string
|
||||||
|
floatingTexts: Array<{
|
||||||
|
id: number
|
||||||
|
memberId: string
|
||||||
|
value: number
|
||||||
|
}>
|
||||||
partySize: number
|
partySize: number
|
||||||
selectedId: string
|
selectedId: string
|
||||||
log: CombatLogEntry[]
|
log: CombatLogEntry[]
|
||||||
@@ -426,17 +438,62 @@ export function DualScreenBottomDisplay() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="dual-bottom-display">
|
<main className={`dual-bottom-display ${state.opponentParty ? 'pvp-opponent-bottom-display' : ''}`}>
|
||||||
<header className="dual-controls-header">
|
<header className="dual-controls-header">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">{state.difficultyName} {state.contentName}</p>
|
<p className="eyebrow">{state.opponentParty ? 'Opponent View' : `${state.difficultyName} ${state.contentName}`}</p>
|
||||||
<h1>{state.dungeonName}</h1>
|
<h1>{state.opponentParty ? state.opponentName : state.dungeonName}</h1>
|
||||||
|
{state.opponentParty && <small>{state.opponentClassName}</small>}
|
||||||
</div>
|
</div>
|
||||||
<div className="dual-controls-progress">
|
<div className="dual-controls-progress">
|
||||||
<span>Encounter {state.encounterIndex + 1}/{state.encounterCount}</span>
|
<span>{state.opponentParty ? 'Live PvP' : `Encounter ${state.encounterIndex + 1}/${state.encounterCount}`}</span>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
{state.opponentParty ? (
|
||||||
|
<>
|
||||||
|
<section className="dual-opponent-progress">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Opponent Clear</p>
|
||||||
|
<strong>{Math.max(0, Math.floor(state.opponentEnemyHealth ?? 0))} / {state.encounterMaxHealth}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="bar enemy-health boss-bar">
|
||||||
|
<span style={{ width: `${Math.max(0, ((state.opponentEnemyHealth ?? 0) / state.encounterMaxHealth) * 100)}%` }} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className={`dual-opponent-party-grid ${state.opponentParty.length > 6 ? 'raid' : ''}`}>
|
||||||
|
{state.opponentParty.map((member) => (
|
||||||
|
<article className={`dual-opponent-member ${member.health <= 0 ? 'dead' : ''}`} key={member.id}>
|
||||||
|
<div className="member-header">
|
||||||
|
<span className={`role role-${member.role.toLowerCase()}`}>{member.role[0]}</span>
|
||||||
|
<strong>{member.name}</strong>
|
||||||
|
<small>{Math.ceil(member.health)} / {member.maxHealth}</small>
|
||||||
|
</div>
|
||||||
|
<div className="bar member-health">
|
||||||
|
<span style={{ width: `${(member.health / member.maxHealth) * 100}%` }} />
|
||||||
|
{member.shield > 0 && (
|
||||||
|
<i style={{ width: `${(member.shield / member.maxHealth) * 100}%` }} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="member-effects">
|
||||||
|
{memberHotEffects(member).map((effect) => (
|
||||||
|
<span className="buff" key={effect.id}>{effect.label}</span>
|
||||||
|
))}
|
||||||
|
{member.debuff && <span className="debuff">{member.debuff}</span>}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="dual-opponent-effects">
|
||||||
|
<span>Buffs: {state.opponentBuffSummary || 'none'}</span>
|
||||||
|
<span>Debuffs: {state.opponentDebuffSummary || 'none'}</span>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
|
||||||
<section className="dual-controls-resource">
|
<section className="dual-controls-resource">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">Active Target</p>
|
<p className="eyebrow">Active Target</p>
|
||||||
@@ -537,6 +594,8 @@ export function DualScreenBottomDisplay() {
|
|||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</section>
|
</section>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -544,9 +603,11 @@ export function DualScreenBottomDisplay() {
|
|||||||
export function DualScreenTopCombat({
|
export function DualScreenTopCombat({
|
||||||
state,
|
state,
|
||||||
onSelectTarget,
|
onSelectTarget,
|
||||||
|
onCastSpell,
|
||||||
}: {
|
}: {
|
||||||
state: DualScreenCombatState
|
state: DualScreenCombatState
|
||||||
onSelectTarget: (id: string) => void
|
onSelectTarget: (id: string) => void
|
||||||
|
onCastSpell?: (spell: Spell) => void
|
||||||
}) {
|
}) {
|
||||||
const enemyPercent = Math.max(
|
const enemyPercent = Math.max(
|
||||||
0,
|
0,
|
||||||
@@ -597,7 +658,11 @@ export function DualScreenTopCombat({
|
|||||||
{member.shield > 0 && (
|
{member.shield > 0 && (
|
||||||
<i style={{ width: `${(member.shield / member.maxHealth) * 100}%` }} />
|
<i style={{ width: `${(member.shield / member.maxHealth) * 100}%` }} />
|
||||||
)}
|
)}
|
||||||
<em className="health-text">{Math.ceil(member.health)} / {member.maxHealth}</em>
|
</div>
|
||||||
|
<div className="floating-combat-texts" aria-hidden="true">
|
||||||
|
{state.floatingTexts
|
||||||
|
.filter((entry) => entry.memberId === member.id)
|
||||||
|
.map((entry) => <span className="floating-heal" key={entry.id}>+{entry.value}</span>)}
|
||||||
</div>
|
</div>
|
||||||
{state.directPartyTargeting && targetBinding && (
|
{state.directPartyTargeting && targetBinding && (
|
||||||
<div className="member-target-key">
|
<div className="member-target-key">
|
||||||
@@ -619,6 +684,40 @@ export function DualScreenTopCombat({
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section className="dual-top-spell-strip">
|
||||||
|
{state.spells.map((spell, slotIndex) => {
|
||||||
|
if (!spell) return <div className="dual-top-spell empty" key={`empty-${slotIndex}`} />
|
||||||
|
const percent = spell.remaining > 0
|
||||||
|
? Math.min(100, (spell.remaining / Math.max(1, spell.cooldown)) * 100)
|
||||||
|
: 0
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className="dual-top-spell"
|
||||||
|
disabled={
|
||||||
|
!state.playerIsAlive
|
||||||
|
|| state.resource < spell.cost
|
||||||
|
|| spell.remaining > 0
|
||||||
|
|| state.status !== 'playing'
|
||||||
|
|| state.paused
|
||||||
|
}
|
||||||
|
key={spell.id}
|
||||||
|
onClick={() => onCastSpell?.(spell)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span className={`spell-icon spell-${spell.kind}`}>{spell.glyph}</span>
|
||||||
|
{spell.remaining > 0 && <i style={{ height: `${percent}%` }} />}
|
||||||
|
{spell.remaining > 0 && <small>{spell.remaining.toFixed(0)}</small>}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
<div className="dual-top-resource">
|
||||||
|
<strong>{state.resourceName} {Math.floor(state.resource)} / {state.maxResource}</strong>
|
||||||
|
<div className="bar mana-bar">
|
||||||
|
<span style={{ width: `${(state.resource / state.maxResource) * 100}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+71
-4
@@ -1,4 +1,5 @@
|
|||||||
import starterProfile from './offline-starter-profile.json'
|
import starterProfile from './offline-starter-profile.json'
|
||||||
|
import { bundledCatalogHash } from './offline-catalog-meta'
|
||||||
import type {
|
import type {
|
||||||
Account,
|
Account,
|
||||||
AuthSession,
|
AuthSession,
|
||||||
@@ -82,6 +83,12 @@ type OnlineCache = {
|
|||||||
dirty: boolean
|
dirty: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CatalogCache = {
|
||||||
|
version: 1
|
||||||
|
hash: string
|
||||||
|
profile: CharacterProfile
|
||||||
|
}
|
||||||
|
|
||||||
export type CloudSyncStatus = {
|
export type CloudSyncStatus = {
|
||||||
available: boolean
|
available: boolean
|
||||||
dirty: boolean
|
dirty: boolean
|
||||||
@@ -102,6 +109,8 @@ type LocalSaveStore = {
|
|||||||
const modeKey = 'chronicle.repositoryMode'
|
const modeKey = 'chronicle.repositoryMode'
|
||||||
const offlineSaveKey = 'chronicle.offlineSave.v1'
|
const offlineSaveKey = 'chronicle.offlineSave.v1'
|
||||||
const onlineCacheKey = 'chronicle.onlineCache.v1'
|
const onlineCacheKey = 'chronicle.onlineCache.v1'
|
||||||
|
const catalogCacheKey = 'chronicle.catalog.v1'
|
||||||
|
const catalogBundleKey = 'chronicle.catalog.bundleHash.v1'
|
||||||
const authTokenKey = 'chronicle.authToken.v1'
|
const authTokenKey = 'chronicle.authToken.v1'
|
||||||
const offlineAccount = { id: -1, username: 'Offline' }
|
const offlineAccount = { id: -1, username: 'Offline' }
|
||||||
const ABILITY_SLOT_COUNT = 6
|
const ABILITY_SLOT_COUNT = 6
|
||||||
@@ -281,8 +290,42 @@ function clearOnlineCache() {
|
|||||||
localStorage.removeItem(onlineCacheKey)
|
localStorage.removeItem(onlineCacheKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function bundledCatalog(): CatalogCache {
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
hash: bundledCatalogHash,
|
||||||
|
profile: starterProfile as CharacterProfile,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readCatalogCache(): CatalogCache | null {
|
||||||
|
if (localStorage.getItem(catalogBundleKey) !== bundledCatalogHash) {
|
||||||
|
localStorage.removeItem(catalogCacheKey)
|
||||||
|
localStorage.setItem(catalogBundleKey, bundledCatalogHash)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const serialized = localStorage.getItem(catalogCacheKey)
|
||||||
|
if (!serialized) return null
|
||||||
|
try {
|
||||||
|
const raw = JSON.parse(serialized) as CatalogCache
|
||||||
|
if (raw.version !== 1 || typeof raw.hash !== 'string' || !raw.profile) return null
|
||||||
|
return raw
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeCatalogCache(cache: CatalogCache) {
|
||||||
|
localStorage.setItem(catalogBundleKey, bundledCatalogHash)
|
||||||
|
localStorage.setItem(catalogCacheKey, JSON.stringify(cache))
|
||||||
|
}
|
||||||
|
|
||||||
|
function activeCatalog(): CatalogCache {
|
||||||
|
return readCatalogCache() ?? bundledCatalog()
|
||||||
|
}
|
||||||
|
|
||||||
function buildProfile(save: OfflineSave): CharacterProfile {
|
function buildProfile(save: OfflineSave): CharacterProfile {
|
||||||
const static_ = clone(starterProfile) as CharacterProfile
|
const static_ = clone(activeCatalog().profile)
|
||||||
const cd = save.characters[save.activeClassId]
|
const cd = save.characters[save.activeClassId]
|
||||||
const gameClass = static_.classes.find((c) => c.id === save.activeClassId)!
|
const gameClass = static_.classes.find((c) => c.id === save.activeClassId)!
|
||||||
|
|
||||||
@@ -628,7 +671,7 @@ function getApiBaseUrl(path: string): string {
|
|||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
async function requestJson<T>(path: string, init?: RequestInit): Promise<T> {
|
export async function requestGameApiJson<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
const baseUrl = getApiBaseUrl(path)
|
const baseUrl = getApiBaseUrl(path)
|
||||||
const url = baseUrl ? `${baseUrl}${path}` : path
|
const url = baseUrl ? `${baseUrl}${path}` : path
|
||||||
const headers = new Headers(init?.headers)
|
const headers = new Headers(init?.headers)
|
||||||
@@ -653,10 +696,31 @@ async function requestJson<T>(path: string, init?: RequestInit): Promise<T> {
|
|||||||
return body
|
return body
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function requestJson<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
return requestGameApiJson(path, init)
|
||||||
|
}
|
||||||
|
|
||||||
function isNetworkError(reason: unknown): reason is NetworkError {
|
function isNetworkError(reason: unknown): reason is NetworkError {
|
||||||
return reason instanceof Error && Boolean((reason as NetworkError).network)
|
return reason instanceof Error && Boolean((reason as NetworkError).network)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadServerCatalog(): Promise<CatalogCache> {
|
||||||
|
return requestJson('/api/catalog')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshCatalogFromServer(): Promise<CatalogCache | null> {
|
||||||
|
try {
|
||||||
|
const catalog = await loadServerCatalog()
|
||||||
|
if (catalog.version !== 1 || !catalog.hash || !catalog.profile) return null
|
||||||
|
if (catalog.hash !== activeCatalog().hash || !readCatalogCache()) {
|
||||||
|
writeCatalogCache(catalog)
|
||||||
|
}
|
||||||
|
return catalog
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function cachedOnlineSession(): AuthSession | null {
|
function cachedOnlineSession(): AuthSession | null {
|
||||||
const cache = readOnlineCache()
|
const cache = readOnlineCache()
|
||||||
if (!cache) return null
|
if (!cache) return null
|
||||||
@@ -698,6 +762,7 @@ async function pushServerSyncSave(save: OfflineSave): Promise<{ profile: Charact
|
|||||||
async function finalizeOnlineSession(session: AuthSession): Promise<AuthSession> {
|
async function finalizeOnlineSession(session: AuthSession): Promise<AuthSession> {
|
||||||
const cache = readOnlineCache()
|
const cache = readOnlineCache()
|
||||||
if (session.token) writeAuthToken(session.token)
|
if (session.token) writeAuthToken(session.token)
|
||||||
|
await refreshCatalogFromServer()
|
||||||
if (!session.account || !session.profile) {
|
if (!session.account || !session.profile) {
|
||||||
if (session.account && cache?.account.id === session.account.id) {
|
if (session.account && cache?.account.id === session.account.id) {
|
||||||
return {
|
return {
|
||||||
@@ -848,7 +913,7 @@ const serverRepository: GameRepository = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function emptyCharacterData(classId: number): CharacterData {
|
function emptyCharacterData(classId: number): CharacterData {
|
||||||
const static_ = clone(starterProfile) as CharacterProfile
|
const static_ = clone(activeCatalog().profile)
|
||||||
const gc = static_.classes.find((c) => c.id === classId)!
|
const gc = static_.classes.find((c) => c.id === classId)!
|
||||||
const talentRanks: Record<string, number> = {}
|
const talentRanks: Record<string, number> = {}
|
||||||
for (const t of gc.talents) talentRanks[String(t.id)] = 0
|
for (const t of gc.talents) talentRanks[String(t.id)] = 0
|
||||||
@@ -1544,7 +1609,9 @@ export async function syncCloudSave(): Promise<CharacterProfile> {
|
|||||||
if (!cache) {
|
if (!cache) {
|
||||||
throw new Error('No signed-in save is available for cloud sync.')
|
throw new Error('No signed-in save is available for cloud sync.')
|
||||||
}
|
}
|
||||||
|
await refreshCatalogFromServer()
|
||||||
const synced = await pushServerSyncSave(cache.save)
|
const synced = await pushServerSyncSave(cache.save)
|
||||||
|
await refreshCatalogFromServer()
|
||||||
writeOnlineCache({
|
writeOnlineCache({
|
||||||
version: 1,
|
version: 1,
|
||||||
account: cache.account,
|
account: cache.account,
|
||||||
@@ -1552,7 +1619,7 @@ export async function syncCloudSave(): Promise<CharacterProfile> {
|
|||||||
dirty: false,
|
dirty: false,
|
||||||
})
|
})
|
||||||
writeMode('online')
|
writeMode('online')
|
||||||
return synced.profile
|
return buildProfile(synced.save)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function selectOnlineMode() {
|
export function selectOnlineMode() {
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export const bundledCatalogHash = '07506c52bab428c439f09a9b82e39e6eff2b243fb972d664da48852059bcd937'
|
||||||
@@ -1,5 +1,49 @@
|
|||||||
|
import { requestGameApiJson } from './gameRepository'
|
||||||
|
|
||||||
export type PvpContentType = 'dungeon' | 'raid'
|
export type PvpContentType = 'dungeon' | 'raid'
|
||||||
export type CpuDifficulty = 1 | 2 | 3 | 4 | 5
|
export type CpuDifficulty = 1 | 2 | 3 | 4 | 5
|
||||||
|
export type PvpMatchSide = 'a' | 'b'
|
||||||
|
|
||||||
|
export type PvpPlayerInfo = {
|
||||||
|
side: PvpMatchSide
|
||||||
|
accountId: number
|
||||||
|
characterId: number
|
||||||
|
characterName: string
|
||||||
|
className: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PvpUpgradeChoicePayload = {
|
||||||
|
encounterIndex: number
|
||||||
|
buffId: string
|
||||||
|
debuffId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PvpMatchSnapshot<TSideState = unknown> = {
|
||||||
|
id: string
|
||||||
|
contentType: PvpContentType
|
||||||
|
startStage: number
|
||||||
|
createdAt: number
|
||||||
|
players: Record<PvpMatchSide, PvpPlayerInfo>
|
||||||
|
states: Partial<Record<PvpMatchSide, TSideState>>
|
||||||
|
statuses: Partial<Record<PvpMatchSide, 'playing' | 'upgrade-choice' | 'won' | 'lost'>>
|
||||||
|
progress: Partial<Record<PvpMatchSide, {
|
||||||
|
stage: number
|
||||||
|
encounterIndex: number
|
||||||
|
encountersCleared: number
|
||||||
|
enemyHealth: number
|
||||||
|
alive: boolean
|
||||||
|
elapsedTicks: number
|
||||||
|
}>>
|
||||||
|
upgradeChoices: Partial<Record<PvpMatchSide, Record<string, PvpUpgradeChoicePayload>>>
|
||||||
|
updatedAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PvpQueueResponse<TSideState = unknown> = {
|
||||||
|
ticketId: string
|
||||||
|
status: 'waiting' | 'matched'
|
||||||
|
match?: PvpMatchSnapshot<TSideState>
|
||||||
|
side?: PvpMatchSide
|
||||||
|
}
|
||||||
|
|
||||||
export type CpuPvpLeaderboardEntry = {
|
export type CpuPvpLeaderboardEntry = {
|
||||||
characterName: string
|
characterName: string
|
||||||
@@ -66,3 +110,59 @@ export function recordPvpRoguelikeCheckpoint(
|
|||||||
localStorage.setItem(checkpointStorageKey(characterId, contentType), String(next))
|
localStorage.setItem(checkpointStorageKey(characterId, contentType), String(next))
|
||||||
return next
|
return next
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function joinPvpQueue<TSideState>(
|
||||||
|
contentType: PvpContentType,
|
||||||
|
startStage: number,
|
||||||
|
): Promise<PvpQueueResponse<TSideState>> {
|
||||||
|
return requestGameApiJson('/api/pvp/queue', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ contentType, startStage }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkPvpQueue<TSideState>(ticketId: string): Promise<PvpQueueResponse<TSideState>> {
|
||||||
|
return requestGameApiJson(`/api/pvp/queue/${encodeURIComponent(ticketId)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cancelPvpQueue(ticketId: string): Promise<{ ok: true }> {
|
||||||
|
return requestGameApiJson(`/api/pvp/queue/${encodeURIComponent(ticketId)}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function publishPvpMatchState<TSideState>(
|
||||||
|
matchId: string,
|
||||||
|
payload: {
|
||||||
|
state: TSideState
|
||||||
|
status: 'playing' | 'upgrade-choice' | 'won' | 'lost'
|
||||||
|
stage: number
|
||||||
|
encounterIndex: number
|
||||||
|
encountersCleared: number
|
||||||
|
enemyHealth: number
|
||||||
|
alive: boolean
|
||||||
|
elapsedTicks: number
|
||||||
|
},
|
||||||
|
): Promise<PvpMatchSnapshot<TSideState>> {
|
||||||
|
return requestGameApiJson(`/api/pvp/matches/${encodeURIComponent(matchId)}/state`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadPvpMatch<TSideState>(matchId: string): Promise<PvpMatchSnapshot<TSideState>> {
|
||||||
|
return requestGameApiJson(`/api/pvp/matches/${encodeURIComponent(matchId)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function submitPvpUpgradeChoice(
|
||||||
|
matchId: string,
|
||||||
|
payload: PvpUpgradeChoicePayload,
|
||||||
|
): Promise<PvpMatchSnapshot> {
|
||||||
|
return requestGameApiJson(`/api/pvp/matches/${encodeURIComponent(matchId)}/upgrade-choice`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user