Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bbcc67a54a | ||
|
|
c6251167a5 | ||
|
|
cbe42b6164 | ||
|
|
c2888c287b | ||
|
|
4703017832 | ||
|
|
c0f2daccb1 | ||
|
|
abdf4cc654 | ||
|
|
5449276521 | ||
|
|
787e2bbae9 | ||
|
|
421540c52b | ||
|
|
1e24aecad8 | ||
|
|
c9fb28ab6d | ||
|
|
c1e2c6d8b5 | ||
|
|
f7b041f86f | ||
|
|
05bd70a9fe |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -7,8 +7,8 @@ android {
|
||||
applicationId "com.warren.iwanttoheal"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 66
|
||||
versionName "1.0.46"
|
||||
versionCode 82
|
||||
versionName "1.1.3"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
import android.os.SystemClock;
|
||||
import android.view.KeyEvent;
|
||||
import android.view.MotionEvent;
|
||||
import android.view.View;
|
||||
import com.getcapacitor.BridgeActivity;
|
||||
import java.io.File;
|
||||
@@ -37,6 +38,18 @@ public abstract class ControllerBridgeActivity extends BridgeActivity {
|
||||
if (hasFocus) enableImmersiveMode();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean dispatchTouchEvent(MotionEvent event) {
|
||||
if (
|
||||
event.getActionMasked() == MotionEvent.ACTION_DOWN
|
||||
&& bridge != null
|
||||
&& bridge.getWebView() != null
|
||||
) {
|
||||
bridge.getWebView().requestFocus();
|
||||
}
|
||||
return super.dispatchTouchEvent(event);
|
||||
}
|
||||
|
||||
private void loadIntentUrl() {
|
||||
if (bridge == null || getIntent() == null) return;
|
||||
String initialUrl = getIntent().getStringExtra(EXTRA_INITIAL_URL);
|
||||
@@ -86,7 +99,10 @@ public abstract class ControllerBridgeActivity extends BridgeActivity {
|
||||
"window.dispatchEvent(new CustomEvent('ashen-halls-native-controller',"
|
||||
+ "{detail:{token:'" + token + "',repeat:" + repeat + "}}));";
|
||||
bridge.getWebView().post(
|
||||
() -> bridge.getWebView().evaluateJavascript(script, null)
|
||||
() -> {
|
||||
bridge.getWebView().requestFocus();
|
||||
bridge.getWebView().evaluateJavascript(script, null);
|
||||
}
|
||||
);
|
||||
}
|
||||
return true;
|
||||
|
||||
@@ -254,6 +254,61 @@ CREATE TABLE IF NOT EXISTS character_inventory (
|
||||
PRIMARY KEY (character_id, item_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS action_characters (
|
||||
id INTEGER PRIMARY KEY,
|
||||
account_id INTEGER REFERENCES accounts(id) ON DELETE CASCADE,
|
||||
class_id INTEGER NOT NULL REFERENCES classes(id),
|
||||
name TEXT NOT NULL,
|
||||
level INTEGER NOT NULL DEFAULT 1,
|
||||
experience INTEGER NOT NULL DEFAULT 0,
|
||||
bulldrome_coins INTEGER NOT NULL DEFAULT 0,
|
||||
bulldrome_normal_clears INTEGER NOT NULL DEFAULT 0,
|
||||
bulldrome_hard_clears INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE (account_id, class_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS action_gear_items (
|
||||
id INTEGER PRIMARY KEY,
|
||||
slug TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
slot TEXT NOT NULL CHECK (slot IN ('weapon', 'helmet', 'chest', 'gloves', 'boots', 'pants', 'ring', 'necklace', 'trinket')),
|
||||
item_level INTEGER NOT NULL CHECK (item_level BETWEEN 1 AND 5),
|
||||
source TEXT NOT NULL DEFAULT 'bulldrome',
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS action_character_inventory (
|
||||
id INTEGER PRIMARY KEY,
|
||||
action_character_id INTEGER NOT NULL REFERENCES action_characters(id) ON DELETE CASCADE,
|
||||
item_id INTEGER REFERENCES items(id),
|
||||
action_gear_item_id INTEGER REFERENCES action_gear_items(id),
|
||||
quantity INTEGER NOT NULL DEFAULT 1 CHECK (quantity > 0),
|
||||
equipped INTEGER NOT NULL DEFAULT 0 CHECK (equipped IN (0, 1))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS action_dungeon_runs (
|
||||
id INTEGER PRIMARY KEY,
|
||||
action_character_id INTEGER NOT NULL REFERENCES action_characters(id) ON DELETE CASCADE,
|
||||
dungeon_id INTEGER NOT NULL REFERENCES dungeons(id),
|
||||
difficulty TEXT NOT NULL CHECK (difficulty IN ('normal', 'hard')),
|
||||
result TEXT NOT NULL CHECK (result IN ('clear', 'wipe')),
|
||||
coins_awarded INTEGER NOT NULL DEFAULT 0,
|
||||
duration_seconds INTEGER NOT NULL DEFAULT 0,
|
||||
completed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS action_encounter_loot_rolls (
|
||||
id INTEGER PRIMARY KEY,
|
||||
action_character_id INTEGER NOT NULL REFERENCES action_characters(id) ON DELETE CASCADE,
|
||||
dungeon_run_id INTEGER REFERENCES action_dungeon_runs(id) ON DELETE CASCADE,
|
||||
encounter_id INTEGER NOT NULL REFERENCES encounters(id),
|
||||
item_id INTEGER NOT NULL REFERENCES items(id),
|
||||
quantity INTEGER NOT NULL DEFAULT 1 CHECK (quantity > 0),
|
||||
difficulty TEXT NOT NULL CHECK (difficulty IN ('normal', 'hard')),
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS encounter_loot_rolls (
|
||||
id INTEGER PRIMARY KEY,
|
||||
character_id INTEGER NOT NULL REFERENCES characters(id) ON DELETE CASCADE,
|
||||
|
||||
+15
@@ -1908,6 +1908,21 @@ JOIN coin_sources
|
||||
ON coin_sources.encounter_id = crafting_recipes.source_encounter_id
|
||||
AND coin_sources.difficulty_id = crafting_recipes.difficulty_id;
|
||||
|
||||
DELETE FROM crafting_recipe_components
|
||||
WHERE recipe_id IN (1101, 1102, 1103);
|
||||
|
||||
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity)
|
||||
SELECT recipe_id, items.id, quantity
|
||||
FROM (
|
||||
SELECT 1101 AS recipe_id, 'tigrex-boss-coin-diff-2-ilvl-10' AS item_slug, 5 AS quantity
|
||||
UNION ALL SELECT 1101, 'bulldrome-boss-coin-diff-2-ilvl-10', 5
|
||||
UNION ALL SELECT 1102, 'tigrex-boss-coin-diff-2-ilvl-10', 5
|
||||
UNION ALL SELECT 1102, 'bulldrome-boss-coin-diff-2-ilvl-10', 5
|
||||
UNION ALL SELECT 1103, 'tigrex-boss-coin-diff-2-ilvl-10', 5
|
||||
UNION ALL SELECT 1103, 'bulldrome-boss-coin-diff-2-ilvl-10', 5
|
||||
) AS requirements
|
||||
JOIN items ON items.slug = requirements.item_slug;
|
||||
|
||||
DELETE FROM gear_upgrade_paths;
|
||||
|
||||
INSERT INTO gear_upgrade_paths (from_item_id, to_item_id)
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1440" height="900" viewBox="0 0 1440 900">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" x2="1" y1="0" y2="1"><stop offset="0" stop-color="#111827"/><stop offset="1" stop-color="#2f1f16"/></linearGradient>
|
||||
<style>
|
||||
text{font-family:Inter,Arial,sans-serif;fill:#f8f1df}.muted{fill:#c8bca6;font-size:20px}.tiny{fill:#d8cab1;font-size:16px}.title{font-size:30px;font-weight:800}.label{font-size:19px;font-weight:700}.panel{fill:#1b2433;stroke:#6f5535;stroke-width:2;rx:12}.tile{fill:#253244;stroke:#7d6743;stroke-width:2;rx:10}.selected{fill:#31483b;stroke:#d7aa45;stroke-width:4;rx:10}.bar-bg{fill:#2a3444;rx:8}.hp{fill:#5ed17a;rx:8}.danger{fill:#d96b55;rx:8}.mana{fill:#63a9ff;rx:8}.button{fill:#d7aa45;rx:10}.spell{fill:#273449;stroke:#8d7349;stroke-width:2;rx:10}
|
||||
</style>
|
||||
</defs>
|
||||
<rect width="1440" height="900" fill="url(#bg)"/>
|
||||
<text x="48" y="64" class="title">PC Dungeon PvP Roguelike</text>
|
||||
<text x="48" y="96" class="muted">Dungeon party = 6 members per side. Upgrade timer auto-picks at 0s.</text>
|
||||
|
||||
<rect x="36" y="132" width="402" height="704" class="panel"/>
|
||||
<text x="64" y="176" class="title">You</text><text x="64" y="204" class="muted">Mira - Priest</text>
|
||||
<text x="64" y="252" class="label">Party Health</text>
|
||||
<g transform="translate(64 278)">
|
||||
<rect width="150" height="82" class="selected"/><text x="16" y="31" class="label">Tank</text><text x="106" y="31" class="tiny">82%</text><rect x="16" y="52" width="118" height="12" class="bar-bg"/><rect x="16" y="52" width="97" height="12" class="hp"/>
|
||||
<rect x="170" width="150" height="82" class="tile"/><text x="186" y="31" class="label">Mira</text><text x="276" y="31" class="tiny">74%</text><rect x="186" y="52" width="118" height="12" class="bar-bg"/><rect x="186" y="52" width="87" height="12" class="hp"/>
|
||||
<rect y="104" width="150" height="82" class="tile"/><text x="16" y="135" class="label">DPS</text><text x="106" y="135" class="tiny">55%</text><rect x="16" y="156" width="118" height="12" class="bar-bg"/><rect x="16" y="156" width="65" height="12" class="danger"/>
|
||||
<rect x="170" y="104" width="150" height="82" class="tile"/><text x="186" y="135" class="label">DPS</text><text x="276" y="135" class="tiny">92%</text><rect x="186" y="156" width="118" height="12" class="bar-bg"/><rect x="186" y="156" width="109" height="12" class="hp"/>
|
||||
<rect y="208" width="150" height="82" class="tile"/><text x="16" y="239" class="label">DPS</text><text x="106" y="239" class="tiny">66%</text><rect x="16" y="260" width="118" height="12" class="bar-bg"/><rect x="16" y="260" width="78" height="12" class="hp"/>
|
||||
<rect x="170" y="208" width="150" height="82" class="tile"/><text x="186" y="239" class="label">DPS</text><text x="276" y="239" class="tiny">41%</text><rect x="186" y="260" width="118" height="12" class="bar-bg"/><rect x="186" y="260" width="48" height="12" class="danger"/>
|
||||
</g>
|
||||
<text x="64" y="626" class="tiny">Buffs: Wide Radiance x1</text><text x="64" y="654" class="tiny">Debuffs: Mana Squeeze x1</text>
|
||||
|
||||
<rect x="472" y="132" width="496" height="704" class="panel"/>
|
||||
<text x="504" y="176" class="title">Stage 15 Boss</text><text x="504" y="204" class="muted">Bulldrome Guardian</text>
|
||||
<text x="504" y="260" class="label">Your Clear</text><rect x="504" y="278" width="400" height="24" class="bar-bg"/><rect x="504" y="278" width="176" height="24" class="danger"/>
|
||||
<text x="504" y="342" class="label">Astra Clear</text><rect x="504" y="360" width="400" height="24" class="bar-bg"/><rect x="504" y="360" width="246" height="24" class="danger"/>
|
||||
<g transform="translate(520 436)">
|
||||
<rect width="400" height="260" fill="#161f2c" stroke="#d7aa45" stroke-width="3" rx="14"/>
|
||||
<text x="32" y="44" class="title">Choose Edge</text><text x="274" y="44" class="title">07.4s</text>
|
||||
<text x="32" y="88" class="label">Self Buff</text><text x="210" y="88" class="label">Opponent Debuff</text>
|
||||
<rect x="32" y="112" width="150" height="52" class="selected"/><text x="46" y="145" class="tiny">+1 Target</text>
|
||||
<rect x="32" y="178" width="150" height="52" class="tile"/><text x="46" y="211" class="tiny">-25% Cost</text>
|
||||
<rect x="210" y="112" width="150" height="52" class="tile"/><text x="224" y="145" class="tiny">Cost Up</text>
|
||||
<rect x="210" y="178" width="150" height="52" class="selected"/><text x="224" y="211" class="tiny">Mana Squeeze</text>
|
||||
</g>
|
||||
<g transform="translate(520 728)">
|
||||
<rect width="56" height="52" class="spell"/><text x="22" y="34" font-size="24" font-weight="800">+</text>
|
||||
<rect x="68" width="56" height="52" class="spell"/><text x="88" y="34" font-size="24" font-weight="800">R</text>
|
||||
<rect x="136" width="56" height="52" class="spell"/><text x="156" y="34" font-size="24" font-weight="800">S</text>
|
||||
<rect x="204" width="56" height="52" class="spell"/><text x="224" y="34" font-size="24" font-weight="800">G</text>
|
||||
<rect x="272" width="56" height="52" class="spell"/><text x="292" y="34" font-size="24" font-weight="800">C</text>
|
||||
<rect x="340" width="56" height="52" class="spell"/><text x="360" y="34" font-size="24" font-weight="800">B</text>
|
||||
</g>
|
||||
|
||||
<rect x="1002" y="132" width="402" height="704" class="panel"/>
|
||||
<text x="1030" y="176" class="title">Opponent</text><text x="1030" y="204" class="muted">Astra - Druid</text>
|
||||
<text x="1030" y="252" class="label">Opponent Party</text>
|
||||
<g transform="translate(1030 278)">
|
||||
<rect width="150" height="82" class="tile"/><text x="16" y="31" class="label">Tank</text><text x="106" y="31" class="tiny">59%</text><rect x="16" y="52" width="118" height="12" class="bar-bg"/><rect x="16" y="52" width="70" height="12" class="danger"/>
|
||||
<rect x="170" width="150" height="82" class="tile"/><text x="186" y="31" class="label">Astra</text><text x="276" y="31" class="tiny">88%</text><rect x="186" y="52" width="118" height="12" class="bar-bg"/><rect x="186" y="52" width="104" height="12" class="hp"/>
|
||||
<rect y="104" width="150" height="82" class="tile"/><text x="16" y="135" class="label">DPS</text><text x="106" y="135" class="tiny">73%</text><rect x="16" y="156" width="118" height="12" class="bar-bg"/><rect x="16" y="156" width="86" height="12" class="hp"/>
|
||||
<rect x="170" y="104" width="150" height="82" class="tile"/><text x="186" y="135" class="label">DPS</text><text x="276" y="135" class="tiny">42%</text><rect x="186" y="156" width="118" height="12" class="bar-bg"/><rect x="186" y="156" width="50" height="12" class="danger"/>
|
||||
<rect y="208" width="150" height="82" class="tile"/><text x="16" y="239" class="label">DPS</text><text x="106" y="239" class="tiny">91%</text><rect x="16" y="260" width="118" height="12" class="bar-bg"/><rect x="16" y="260" width="107" height="12" class="hp"/>
|
||||
<rect x="170" y="208" width="150" height="82" class="tile"/><text x="186" y="239" class="label">DPS</text><text x="276" y="239" class="tiny">66%</text><rect x="186" y="260" width="118" height="12" class="bar-bg"/><rect x="186" y="260" width="78" height="12" class="hp"/>
|
||||
</g>
|
||||
<text x="1030" y="626" class="tiny">Buffs: Dense Shields x1</text><text x="1030" y="654" class="tiny">Debuffs: Cost Up x1</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 7.1 KiB |
@@ -0,0 +1,30 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540" viewBox="0 0 960 540">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" x2="1" y1="0" y2="1"><stop offset="0" stop-color="#101827"/><stop offset="1" stop-color="#271a12"/></linearGradient>
|
||||
<style>
|
||||
text{font-family:Inter,Arial,sans-serif;fill:#f8f1df}.muted{fill:#c8bca6;font-size:15px}.title{font-size:24px;font-weight:800}.label{font-size:16px;font-weight:700}.panel{fill:#1b2433;stroke:#6f5535;stroke-width:2;rx:10}.tile{fill:#253244;stroke:#7d6743;stroke-width:2;rx:8}.selected{fill:#31483b;stroke:#d7aa45;stroke-width:4;rx:8}.bar-bg{fill:#2a3444;rx:7}.hp{fill:#5ed17a;rx:7}.danger{fill:#d96b55;rx:7}.mana{fill:#63a9ff;rx:7}.spell{fill:#273449;stroke:#8d7349;stroke-width:2;rx:10}.ready{fill:#31483b;stroke:#d7aa45;stroke-width:3;rx:10}
|
||||
</style>
|
||||
</defs>
|
||||
<rect width="960" height="540" fill="url(#bg)"/>
|
||||
<text x="24" y="38" class="title">Thor Main - Dungeon PvP</text>
|
||||
<text x="24" y="62" class="muted">Dungeon party shows exactly 6 members. Opponent health stays on bottom screen.</text>
|
||||
<rect x="24" y="86" width="912" height="350" class="panel"/>
|
||||
<text x="52" y="124" class="title">Mira Party</text><text x="760" y="124" class="label">Stage 15 Boss</text>
|
||||
<rect x="760" y="140" width="132" height="16" class="bar-bg"/><rect x="760" y="140" width="58" height="16" class="danger"/>
|
||||
<g transform="translate(52 164)">
|
||||
<rect width="132" height="96" class="selected"/><text x="16" y="28" class="label">Tank</text><text x="16" y="52" class="muted">82 / 100</text><rect x="16" y="68" width="100" height="12" class="bar-bg"/><rect x="16" y="68" width="82" height="12" class="hp"/>
|
||||
<rect x="150" width="132" height="96" class="tile"/><text x="166" y="28" class="label">Mira</text><text x="166" y="52" class="muted">74 / 100</text><rect x="166" y="68" width="100" height="12" class="bar-bg"/><rect x="166" y="68" width="74" height="12" class="hp"/>
|
||||
<rect x="300" width="132" height="96" class="tile"/><text x="316" y="28" class="label">DPS</text><text x="316" y="52" class="muted">55 / 100</text><rect x="316" y="68" width="100" height="12" class="bar-bg"/><rect x="316" y="68" width="55" height="12" class="danger"/>
|
||||
<rect y="116" width="132" height="96" class="tile"/><text x="16" y="144" class="label">DPS</text><text x="16" y="168" class="muted">92 / 100</text><rect x="16" y="184" width="100" height="12" class="bar-bg"/><rect x="16" y="184" width="92" height="12" class="hp"/>
|
||||
<rect x="150" y="116" width="132" height="96" class="tile"/><text x="166" y="144" class="label">DPS</text><text x="166" y="168" class="muted">66 / 100</text><rect x="166" y="184" width="100" height="12" class="bar-bg"/><rect x="166" y="184" width="66" height="12" class="hp"/>
|
||||
<rect x="300" y="116" width="132" height="96" class="tile"/><text x="316" y="144" class="label">DPS</text><text x="316" y="168" class="muted">41 / 100</text><rect x="316" y="184" width="100" height="12" class="bar-bg"/><rect x="316" y="184" width="41" height="12" class="danger"/>
|
||||
</g>
|
||||
<rect x="24" y="452" width="912" height="68" class="panel"/>
|
||||
<rect x="48" y="468" width="52" height="40" class="ready"/><text x="66" y="496" font-size="22" font-weight="800">+</text>
|
||||
<rect x="112" y="468" width="52" height="40" class="spell"/><text x="129" y="496" font-size="22" font-weight="800">R</text><text x="143" y="483" font-size="12">3</text>
|
||||
<rect x="176" y="468" width="52" height="40" class="spell"/><text x="194" y="496" font-size="22" font-weight="800">S</text><text x="207" y="483" font-size="12">8</text>
|
||||
<rect x="240" y="468" width="52" height="40" class="ready"/><text x="258" y="496" font-size="22" font-weight="800">G</text>
|
||||
<rect x="304" y="468" width="52" height="40" class="spell"/><text x="322" y="496" font-size="22" font-weight="800">C</text><text x="335" y="483" font-size="12">5</text>
|
||||
<rect x="368" y="468" width="52" height="40" class="ready"/><text x="386" y="496" font-size="22" font-weight="800">B</text>
|
||||
<text x="650" y="480" class="label">Mana 73 / 100</text><rect x="650" y="492" width="240" height="14" class="bar-bg"/><rect x="650" y="492" width="175" height="14" class="mana"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.1 KiB |
@@ -0,0 +1,62 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1440" height="900" viewBox="0 0 1440 900">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" x2="1" y1="0" y2="1"><stop offset="0" stop-color="#111827"/><stop offset="1" stop-color="#2f1f16"/></linearGradient>
|
||||
<style>
|
||||
text{font-family:Inter,Arial,sans-serif;fill:#f8f1df}.muted{fill:#c8bca6;font-size:20px}.tiny{fill:#d8cab1;font-size:14px}.title{font-size:30px;font-weight:800}.label{font-size:18px;font-weight:700}.panel{fill:#1b2433;stroke:#6f5535;stroke-width:2;rx:12}.tile{fill:#253244;stroke:#7d6743;stroke-width:2;rx:8}.bar-bg{fill:#2a3444;rx:7}.hp{fill:#5ed17a;rx:7}.danger{fill:#d96b55;rx:7}.spell{fill:#273449;stroke:#8d7349;stroke-width:2;rx:10}.selected{fill:#31483b;stroke:#d7aa45;stroke-width:3;rx:8}
|
||||
</style>
|
||||
</defs>
|
||||
<rect width="1440" height="900" fill="url(#bg)"/>
|
||||
<text x="48" y="64" class="title">PC Raid PvP Roguelike</text>
|
||||
<text x="48" y="96" class="muted">Raid party = compact roster. Opponent panel mirrors raid health without crowding center.</text>
|
||||
<rect x="36" y="132" width="430" height="704" class="panel"/>
|
||||
<text x="64" y="176" class="title">You</text><text x="64" y="204" class="muted">Mira Raid Group</text>
|
||||
<g transform="translate(64 238)">
|
||||
<rect width="104" height="56" class="selected"/><text x="10" y="24" class="tiny">Tank 82%</text><rect x="10" y="36" width="84" height="8" class="bar-bg"/><rect x="10" y="36" width="69" height="8" class="hp"/>
|
||||
<rect x="116" width="104" height="56" class="tile"/><text x="126" y="24" class="tiny">Mira 74%</text><rect x="126" y="36" width="84" height="8" class="bar-bg"/><rect x="126" y="36" width="62" height="8" class="hp"/>
|
||||
<rect x="232" width="104" height="56" class="tile"/><text x="242" y="24" class="tiny">DPS 55%</text><rect x="242" y="36" width="84" height="8" class="bar-bg"/><rect x="242" y="36" width="46" height="8" class="danger"/>
|
||||
<rect y="72" width="104" height="56" class="tile"/><text x="10" y="96" class="tiny">DPS 92%</text><rect x="10" y="108" width="84" height="8" class="bar-bg"/><rect x="10" y="108" width="77" height="8" class="hp"/>
|
||||
<rect x="116" y="72" width="104" height="56" class="tile"/><text x="126" y="96" class="tiny">DPS 66%</text><rect x="126" y="108" width="84" height="8" class="bar-bg"/><rect x="126" y="108" width="55" height="8" class="hp"/>
|
||||
<rect x="232" y="72" width="104" height="56" class="tile"/><text x="242" y="96" class="tiny">DPS 41%</text><rect x="242" y="108" width="84" height="8" class="bar-bg"/><rect x="242" y="108" width="34" height="8" class="danger"/>
|
||||
<rect y="144" width="104" height="56" class="tile"/><text x="10" y="168" class="tiny">DPS 88%</text><rect x="10" y="180" width="84" height="8" class="bar-bg"/><rect x="10" y="180" width="74" height="8" class="hp"/>
|
||||
<rect x="116" y="144" width="104" height="56" class="tile"/><text x="126" y="168" class="tiny">DPS 63%</text><rect x="126" y="180" width="84" height="8" class="bar-bg"/><rect x="126" y="180" width="53" height="8" class="hp"/>
|
||||
<rect x="232" y="144" width="104" height="56" class="tile"/><text x="242" y="168" class="tiny">DPS 77%</text><rect x="242" y="180" width="84" height="8" class="bar-bg"/><rect x="242" y="180" width="65" height="8" class="hp"/>
|
||||
<rect y="216" width="104" height="56" class="tile"/><text x="10" y="240" class="tiny">DPS 49%</text><rect x="10" y="252" width="84" height="8" class="bar-bg"/><rect x="10" y="252" width="41" height="8" class="danger"/>
|
||||
<rect x="116" y="216" width="104" height="56" class="tile"/><text x="126" y="240" class="tiny">DPS 96%</text><rect x="126" y="252" width="84" height="8" class="bar-bg"/><rect x="126" y="252" width="81" height="8" class="hp"/>
|
||||
<rect x="232" y="216" width="104" height="56" class="tile"/><text x="242" y="240" class="tiny">DPS 70%</text><rect x="242" y="252" width="84" height="8" class="bar-bg"/><rect x="242" y="252" width="59" height="8" class="hp"/>
|
||||
</g>
|
||||
<text x="64" y="590" class="tiny">Direct target group: 1 / 2</text><text x="64" y="620" class="tiny">Buffs: Wide Radiance x1</text>
|
||||
|
||||
<rect x="502" y="132" width="436" height="704" class="panel"/>
|
||||
<text x="534" y="176" class="title">Raid Stage 20 Boss</text><text x="534" y="204" class="muted">Ashen Warden</text>
|
||||
<text x="534" y="260" class="label">Your Clear</text><rect x="534" y="278" width="340" height="24" class="bar-bg"/><rect x="534" y="278" width="176" height="24" class="danger"/>
|
||||
<text x="534" y="342" class="label">Astra Clear</text><rect x="534" y="360" width="340" height="24" class="bar-bg"/><rect x="534" y="360" width="218" height="24" class="danger"/>
|
||||
<rect x="534" y="438" width="340" height="220" fill="#161f2c" stroke="#d7aa45" stroke-width="3" rx="14"/>
|
||||
<text x="558" y="482" class="title">Choose Edge</text><text x="766" y="482" class="title">10.0s</text>
|
||||
<rect x="558" y="520" width="132" height="48" class="selected"/><text x="572" y="551" class="tiny">Raid Heal +</text>
|
||||
<rect x="708" y="520" width="132" height="48" class="tile"/><text x="722" y="551" class="tiny">Mana Squeeze</text>
|
||||
<rect x="558" y="584" width="132" height="48" class="tile"/><text x="572" y="615" class="tiny">Shield Boost</text>
|
||||
<rect x="708" y="584" width="132" height="48" class="selected"/><text x="722" y="615" class="tiny">Cooldown Up</text>
|
||||
<g transform="translate(534 724)">
|
||||
<rect width="48" height="48" class="spell"/><text x="18" y="32" font-size="22" font-weight="800">+</text>
|
||||
<rect x="60" width="48" height="48" class="spell"/><text x="77" y="32" font-size="22" font-weight="800">R</text>
|
||||
<rect x="120" width="48" height="48" class="spell"/><text x="137" y="32" font-size="22" font-weight="800">S</text>
|
||||
<rect x="180" width="48" height="48" class="spell"/><text x="197" y="32" font-size="22" font-weight="800">G</text>
|
||||
<rect x="240" width="48" height="48" class="spell"/><text x="257" y="32" font-size="22" font-weight="800">C</text>
|
||||
<rect x="300" width="48" height="48" class="spell"/><text x="317" y="32" font-size="22" font-weight="800">B</text>
|
||||
</g>
|
||||
|
||||
<rect x="974" y="132" width="430" height="704" class="panel"/>
|
||||
<text x="1002" y="176" class="title">Opponent Raid</text><text x="1002" y="204" class="muted">Astra Group</text>
|
||||
<g transform="translate(1002 238)">
|
||||
<rect width="104" height="56" class="tile"/><text x="10" y="24" class="tiny">Tank 61%</text><rect x="10" y="36" width="84" height="8" class="bar-bg"/><rect x="10" y="36" width="51" height="8" class="danger"/>
|
||||
<rect x="116" width="104" height="56" class="tile"/><text x="126" y="24" class="tiny">Astra 89%</text><rect x="126" y="36" width="84" height="8" class="bar-bg"/><rect x="126" y="36" width="75" height="8" class="hp"/>
|
||||
<rect x="232" width="104" height="56" class="tile"/><text x="242" y="24" class="tiny">DPS 73%</text><rect x="242" y="36" width="84" height="8" class="bar-bg"/><rect x="242" y="36" width="61" height="8" class="hp"/>
|
||||
<rect y="72" width="104" height="56" class="tile"/><text x="10" y="96" class="tiny">DPS 42%</text><rect x="10" y="108" width="84" height="8" class="bar-bg"/><rect x="10" y="108" width="35" height="8" class="danger"/>
|
||||
<rect x="116" y="72" width="104" height="56" class="tile"/><text x="126" y="96" class="tiny">DPS 91%</text><rect x="126" y="108" width="84" height="8" class="bar-bg"/><rect x="126" y="108" width="76" height="8" class="hp"/>
|
||||
<rect x="232" y="72" width="104" height="56" class="tile"/><text x="242" y="96" class="tiny">DPS 66%</text><rect x="242" y="108" width="84" height="8" class="bar-bg"/><rect x="242" y="108" width="55" height="8" class="hp"/>
|
||||
<rect y="144" width="104" height="56" class="tile"/><text x="10" y="168" class="tiny">DPS 79%</text><rect x="10" y="180" width="84" height="8" class="bar-bg"/><rect x="10" y="180" width="66" height="8" class="hp"/>
|
||||
<rect x="116" y="144" width="104" height="56" class="tile"/><text x="126" y="168" class="tiny">DPS 58%</text><rect x="126" y="180" width="84" height="8" class="bar-bg"/><rect x="126" y="180" width="49" height="8" class="danger"/>
|
||||
<rect x="232" y="144" width="104" height="56" class="tile"/><text x="242" y="168" class="tiny">DPS 84%</text><rect x="242" y="180" width="84" height="8" class="bar-bg"/><rect x="242" y="180" width="71" height="8" class="hp"/>
|
||||
</g>
|
||||
<text x="1002" y="590" class="tiny">Opponent buffs/debuffs visible here</text><text x="1002" y="620" class="tiny">Raid bottom screen can scroll second group if needed</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 8.3 KiB |
@@ -0,0 +1,36 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540" viewBox="0 0 960 540">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" x2="1" y1="0" y2="1"><stop offset="0" stop-color="#101827"/><stop offset="1" stop-color="#271a12"/></linearGradient>
|
||||
<style>
|
||||
text{font-family:Inter,Arial,sans-serif;fill:#f8f1df}.muted{fill:#c8bca6;font-size:15px}.title{font-size:24px;font-weight:800}.label{font-size:15px;font-weight:700}.panel{fill:#1b2433;stroke:#6f5535;stroke-width:2;rx:10}.tile{fill:#253244;stroke:#7d6743;stroke-width:2;rx:8}.selected{fill:#31483b;stroke:#d7aa45;stroke-width:4;rx:8}.bar-bg{fill:#2a3444;rx:7}.hp{fill:#5ed17a;rx:7}.danger{fill:#d96b55;rx:7}.mana{fill:#63a9ff;rx:7}.spell{fill:#273449;stroke:#8d7349;stroke-width:2;rx:10}.ready{fill:#31483b;stroke:#d7aa45;stroke-width:3;rx:10}
|
||||
</style>
|
||||
</defs>
|
||||
<rect width="960" height="540" fill="url(#bg)"/>
|
||||
<text x="24" y="38" class="title">Thor Main - Raid PvP</text>
|
||||
<text x="24" y="62" class="muted">Raid party uses compact tiles plus target group toggle. Bottom screen mirrors opponent raid.</text>
|
||||
<rect x="24" y="86" width="912" height="350" class="panel"/>
|
||||
<text x="52" y="122" class="title">Mira Raid</text><text x="214" y="122" class="muted">Target Group 1 / 2</text>
|
||||
<text x="760" y="122" class="label">Raid Boss</text><rect x="760" y="138" width="132" height="16" class="bar-bg"/><rect x="760" y="138" width="72" height="16" class="danger"/>
|
||||
<g transform="translate(52 156)">
|
||||
<rect width="104" height="72" class="selected"/><text x="12" y="28" class="label">Tank</text><text x="62" y="28" class="muted">82%</text><rect x="12" y="46" width="80" height="10" class="bar-bg"/><rect x="12" y="46" width="66" height="10" class="hp"/>
|
||||
<rect x="118" width="104" height="72" class="tile"/><text x="130" y="28" class="label">Mira</text><text x="180" y="28" class="muted">74%</text><rect x="130" y="46" width="80" height="10" class="bar-bg"/><rect x="130" y="46" width="59" height="10" class="hp"/>
|
||||
<rect x="236" width="104" height="72" class="tile"/><text x="248" y="28" class="label">DPS</text><text x="298" y="28" class="muted">55%</text><rect x="248" y="46" width="80" height="10" class="bar-bg"/><rect x="248" y="46" width="44" height="10" class="danger"/>
|
||||
<rect x="354" width="104" height="72" class="tile"/><text x="366" y="28" class="label">DPS</text><text x="416" y="28" class="muted">92%</text><rect x="366" y="46" width="80" height="10" class="bar-bg"/><rect x="366" y="46" width="74" height="10" class="hp"/>
|
||||
<rect x="472" width="104" height="72" class="tile"/><text x="484" y="28" class="label">DPS</text><text x="534" y="28" class="muted">66%</text><rect x="484" y="46" width="80" height="10" class="bar-bg"/><rect x="484" y="46" width="53" height="10" class="hp"/>
|
||||
<rect x="590" width="104" height="72" class="tile"/><text x="602" y="28" class="label">DPS</text><text x="652" y="28" class="muted">41%</text><rect x="602" y="46" width="80" height="10" class="bar-bg"/><rect x="602" y="46" width="33" height="10" class="danger"/>
|
||||
<rect y="92" width="104" height="72" class="tile"/><text x="12" y="120" class="label">DPS</text><text x="62" y="120" class="muted">88%</text><rect x="12" y="138" width="80" height="10" class="bar-bg"/><rect x="12" y="138" width="70" height="10" class="hp"/>
|
||||
<rect x="118" y="92" width="104" height="72" class="tile"/><text x="130" y="120" class="label">DPS</text><text x="180" y="120" class="muted">63%</text><rect x="130" y="138" width="80" height="10" class="bar-bg"/><rect x="130" y="138" width="50" height="10" class="hp"/>
|
||||
<rect x="236" y="92" width="104" height="72" class="tile"/><text x="248" y="120" class="label">DPS</text><text x="298" y="120" class="muted">77%</text><rect x="248" y="138" width="80" height="10" class="bar-bg"/><rect x="248" y="138" width="62" height="10" class="hp"/>
|
||||
<rect x="354" y="92" width="104" height="72" class="tile"/><text x="366" y="120" class="label">DPS</text><text x="416" y="120" class="muted">49%</text><rect x="366" y="138" width="80" height="10" class="bar-bg"/><rect x="366" y="138" width="39" height="10" class="danger"/>
|
||||
<rect x="472" y="92" width="104" height="72" class="tile"/><text x="484" y="120" class="label">DPS</text><text x="534" y="120" class="muted">96%</text><rect x="484" y="138" width="80" height="10" class="bar-bg"/><rect x="484" y="138" width="77" height="10" class="hp"/>
|
||||
<rect x="590" y="92" width="104" height="72" class="tile"/><text x="602" y="120" class="label">DPS</text><text x="652" y="120" class="muted">70%</text><rect x="602" y="138" width="80" height="10" class="bar-bg"/><rect x="602" y="138" width="56" height="10" class="hp"/>
|
||||
</g>
|
||||
<rect x="24" y="452" width="912" height="68" class="panel"/>
|
||||
<rect x="48" y="468" width="52" height="40" class="ready"/><text x="66" y="496" font-size="22" font-weight="800">+</text>
|
||||
<rect x="112" y="468" width="52" height="40" class="spell"/><text x="129" y="496" font-size="22" font-weight="800">R</text><text x="143" y="483" font-size="12">3</text>
|
||||
<rect x="176" y="468" width="52" height="40" class="spell"/><text x="194" y="496" font-size="22" font-weight="800">S</text><text x="207" y="483" font-size="12">8</text>
|
||||
<rect x="240" y="468" width="52" height="40" class="ready"/><text x="258" y="496" font-size="22" font-weight="800">G</text>
|
||||
<rect x="304" y="468" width="52" height="40" class="spell"/><text x="322" y="496" font-size="22" font-weight="800">C</text><text x="335" y="483" font-size="12">5</text>
|
||||
<rect x="368" y="468" width="52" height="40" class="ready"/><text x="386" y="496" font-size="22" font-weight="800">B</text>
|
||||
<text x="650" y="480" class="label">Mana 73 / 100</text><rect x="650" y="492" width="240" height="14" class="bar-bg"/><rect x="650" y="492" width="175" height="14" class="mana"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.7 KiB |
@@ -0,0 +1,41 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="620" height="540" viewBox="0 0 620 540">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" x2="1" y1="0" y2="1">
|
||||
<stop offset="0" stop-color="#101827"/>
|
||||
<stop offset="1" stop-color="#271a12"/>
|
||||
</linearGradient>
|
||||
<style>
|
||||
text { font-family: Inter, Arial, sans-serif; fill: #f8f1df; }
|
||||
.muted { fill: #c8bca6; font-size: 14px; }
|
||||
.title { font-size: 22px; font-weight: 800; }
|
||||
.label { font-size: 15px; font-weight: 700; }
|
||||
.panel { fill: #1b2433; stroke: #6f5535; stroke-width: 2; rx: 10; }
|
||||
.tile { fill: #253244; stroke: #7d6743; stroke-width: 2; rx: 8; }
|
||||
.bar-bg { fill: #2a3444; rx: 7; }
|
||||
.hp { fill: #5ed17a; rx: 7; }
|
||||
.danger { fill: #d96b55; rx: 7; }
|
||||
.mana { fill: #63a9ff; rx: 7; }
|
||||
</style>
|
||||
</defs>
|
||||
<rect width="620" height="540" fill="url(#bg)"/>
|
||||
<text x="20" y="36" class="title">Thor Bottom Screen - Opponent View</text>
|
||||
<text x="20" y="60" class="muted">Secondary display shows real opponent health/progress.</text>
|
||||
<rect x="20" y="82" width="580" height="424" class="panel"/>
|
||||
<text x="44" y="122" class="title">Astra</text>
|
||||
<text x="44" y="146" class="muted">Druid - live opponent</text>
|
||||
<text x="376" y="122" class="label">Opponent Clear</text>
|
||||
<rect x="376" y="136" width="172" height="16" class="bar-bg"/><rect x="376" y="136" width="106" height="16" class="danger"/>
|
||||
<text x="44" y="192" class="label">Opponent Party Health</text>
|
||||
<g transform="translate(44 216)">
|
||||
<rect width="158" height="72" class="tile"/><text x="14" y="26" class="label">Tank</text><text x="104" y="26" class="muted">59%</text><rect x="14" y="44" width="128" height="12" class="bar-bg"/><rect x="14" y="44" width="76" height="12" class="danger"/>
|
||||
<rect x="176" width="158" height="72" class="tile"/><text x="190" y="26" class="label">Astra</text><text x="280" y="26" class="muted">88%</text><rect x="190" y="44" width="128" height="12" class="bar-bg"/><rect x="190" y="44" width="113" height="12" class="hp"/>
|
||||
<rect x="352" width="158" height="72" class="tile"/><text x="366" y="26" class="label">DPS</text><text x="456" y="26" class="muted">73%</text><rect x="366" y="44" width="128" height="12" class="bar-bg"/><rect x="366" y="44" width="93" height="12" class="hp"/>
|
||||
<rect y="92" width="158" height="72" class="tile"/><text x="14" y="118" class="label">DPS</text><text x="104" y="118" class="muted">42%</text><rect x="14" y="136" width="128" height="12" class="bar-bg"/><rect x="14" y="136" width="54" height="12" class="danger"/>
|
||||
<rect x="176" y="92" width="158" height="72" class="tile"/><text x="190" y="118" class="label">DPS</text><text x="280" y="118" class="muted">91%</text><rect x="190" y="136" width="128" height="12" class="bar-bg"/><rect x="190" y="136" width="116" height="12" class="hp"/>
|
||||
<rect x="352" y="92" width="158" height="72" class="tile"/><text x="366" y="118" class="label">DPS</text><text x="456" y="118" class="muted">66%</text><rect x="366" y="136" width="128" height="12" class="bar-bg"/><rect x="366" y="136" width="84" height="12" class="hp"/>
|
||||
</g>
|
||||
<text x="44" y="420" class="label">Mana 86 / 100</text>
|
||||
<rect x="154" y="408" width="214" height="16" class="bar-bg"/><rect x="154" y="408" width="184" height="16" class="mana"/>
|
||||
<text x="44" y="462" class="muted">Buffs: Dense Shields x1</text>
|
||||
<text x="318" y="462" class="muted">Debuffs: Cost Up x1</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.4 KiB |
@@ -12,6 +12,8 @@
|
||||
"android:open": "cap open android",
|
||||
"android:apk": "npm run android:sync && cd android && ./gradlew clean assembleDebug",
|
||||
"android:apk:truenas": "VITE_API_BASE_URL=https://iwanttoheal.phenomrom.com npm run android:apk",
|
||||
"release:gui": "python3 scripts/release_game.py",
|
||||
"release:cli": "python3 scripts/release_game_cli.py",
|
||||
"accounts:ip": "node scripts/manage-ip-allowance.mjs",
|
||||
"db:backup": "node scripts/backup-db.mjs",
|
||||
"db:init": "node scripts/init-db.mjs",
|
||||
|
||||
Binary file not shown.
@@ -1,5 +1,6 @@
|
||||
import { readFileSync, writeFileSync } from 'node:fs'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import { catalogPayload } from '../server/catalog.mjs'
|
||||
import { getProfile } from '../server/game-api.mjs'
|
||||
|
||||
const database = new DatabaseSync(':memory:')
|
||||
@@ -7,10 +8,14 @@ const database = new DatabaseSync(':memory:')
|
||||
try {
|
||||
database.exec(readFileSync('db/schema.sql', 'utf8'))
|
||||
database.exec(readFileSync('db/seed.sql', 'utf8'))
|
||||
const profile = getProfile(database, 1)
|
||||
const catalog = catalogPayload(getProfile(database, 1))
|
||||
writeFileSync(
|
||||
'src/offline-starter-profile.json',
|
||||
`${JSON.stringify(profile, null, 2)}\n`,
|
||||
`${JSON.stringify(catalog.profile, null, 2)}\n`,
|
||||
)
|
||||
writeFileSync(
|
||||
'src/offline-catalog-meta.ts',
|
||||
`export const bundledCatalogHash = '${catalog.hash}'\n`,
|
||||
)
|
||||
console.log('Offline starter profile exported from SQLite.')
|
||||
} finally {
|
||||
|
||||
Executable
+502
@@ -0,0 +1,502 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import queue
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
from getpass import getpass
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
try:
|
||||
import tkinter as tk
|
||||
from tkinter import messagebox, ttk
|
||||
except ImportError:
|
||||
tk = None
|
||||
messagebox = None
|
||||
ttk = None
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
GRADLE_FILE = REPO_ROOT / "android" / "app" / "build.gradle"
|
||||
DEFAULT_JAVA_HOME = "/Applications/Android Studio.app/Contents/jbr/Contents/Home"
|
||||
DEFAULT_API_BASE_URL = "https://iwanttoheal.phenomrom.com"
|
||||
DEFAULT_GITEA_URL = "https://git.whoagland.com"
|
||||
DEFAULT_GITEA_OWNER = "phenom"
|
||||
DEFAULT_GITEA_REPO = "i-want-to-heal"
|
||||
DEFAULT_BRANCH = "main"
|
||||
DEFAULT_TRUENAS_PATH = "/mnt/usbssds/apps/iwanttoheal/app"
|
||||
DEFAULT_DB_PATH = "/mnt/usbssds/apps/iwanttoheal/data/game.db"
|
||||
|
||||
|
||||
class ReleaseError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReleaseConfig:
|
||||
version: str
|
||||
token: str
|
||||
api_base_url: str
|
||||
java_home: str
|
||||
gitea_url: str
|
||||
gitea_owner: str
|
||||
gitea_repo: str
|
||||
branch: str
|
||||
truenas_path: str
|
||||
truenas_db_path: str
|
||||
restart_command: str
|
||||
run_build: bool
|
||||
run_commit_push: bool
|
||||
run_release: bool
|
||||
run_truenas_pull: bool
|
||||
run_truenas_backup: bool
|
||||
run_restart: bool
|
||||
|
||||
|
||||
def read_current_version() -> str:
|
||||
text = GRADLE_FILE.read_text()
|
||||
match = re.search(r'versionName\s+"([^"]+)"', text)
|
||||
return match.group(1) if match else ""
|
||||
|
||||
|
||||
def run_command(
|
||||
args: list[str],
|
||||
*,
|
||||
cwd: Path,
|
||||
env: dict[str, str] | None,
|
||||
log: Callable[[str], None],
|
||||
) -> None:
|
||||
log(f"$ {' '.join(args)}")
|
||||
proc = subprocess.Popen(
|
||||
args,
|
||||
cwd=cwd,
|
||||
env=env,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
)
|
||||
assert proc.stdout is not None
|
||||
for line in proc.stdout:
|
||||
log(line.rstrip("\n"))
|
||||
code = proc.wait()
|
||||
if code != 0:
|
||||
raise ReleaseError(f"Command failed ({code}): {' '.join(args)}")
|
||||
|
||||
|
||||
def update_gradle_version(version: str, log: Callable[[str], None]) -> int:
|
||||
text = GRADLE_FILE.read_text()
|
||||
code_match = re.search(r"versionCode\s+(\d+)", text)
|
||||
if not code_match:
|
||||
raise ReleaseError(f"Could not find versionCode in {GRADLE_FILE}")
|
||||
|
||||
next_code = int(code_match.group(1)) + 1
|
||||
text = re.sub(r"versionCode\s+\d+", f"versionCode {next_code}", text, count=1)
|
||||
text = re.sub(r'versionName\s+"[^"]+"', f'versionName "{version}"', text, count=1)
|
||||
GRADLE_FILE.write_text(text)
|
||||
log(f"Android version set: versionName {version}, versionCode {next_code}")
|
||||
return next_code
|
||||
|
||||
|
||||
def build_apk(config: ReleaseConfig, log: Callable[[str], None]) -> Path:
|
||||
update_gradle_version(config.version, log)
|
||||
env = os.environ.copy()
|
||||
env["JAVA_HOME"] = config.java_home
|
||||
env["PATH"] = f"{config.java_home}/bin:{env.get('PATH', '')}"
|
||||
env["VITE_API_BASE_URL"] = config.api_base_url
|
||||
|
||||
run_command(["npm", "run", "android:sync"], cwd=REPO_ROOT, env=env, log=log)
|
||||
run_command(["./gradlew", "clean", "assembleDebug"], cwd=REPO_ROOT / "android", env=env, log=log)
|
||||
|
||||
apk_source = REPO_ROOT / "android" / "app" / "build" / "outputs" / "apk" / "debug" / "app-debug.apk"
|
||||
if not apk_source.exists():
|
||||
raise ReleaseError(f"APK not found: {apk_source}")
|
||||
apk_target = REPO_ROOT / f"IWantToHeal-Thor-v{config.version}.apk"
|
||||
apk_target.write_bytes(apk_source.read_bytes())
|
||||
log(f"APK copied: {apk_target.name} ({apk_target.stat().st_size:,} bytes)")
|
||||
return apk_target
|
||||
|
||||
|
||||
def commit_and_push(config: ReleaseConfig, log: Callable[[str], None]) -> None:
|
||||
message = f"Android build v{config.version}"
|
||||
run_command(["git", "add", "."], cwd=REPO_ROOT, env=None, log=log)
|
||||
run_command(["git", "commit", "-m", message], cwd=REPO_ROOT, env=None, log=log)
|
||||
run_command(["git", "push", "origin", config.branch], cwd=REPO_ROOT, env=None, log=log)
|
||||
|
||||
|
||||
def gitea_request(
|
||||
config: ReleaseConfig,
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
body: bytes | None = None,
|
||||
content_type: str | None = None,
|
||||
) -> tuple[int, bytes]:
|
||||
url = config.gitea_url.rstrip("/") + path
|
||||
headers = {"Authorization": f"token {config.token}"}
|
||||
if content_type:
|
||||
headers["Content-Type"] = content_type
|
||||
req = urllib.request.Request(url, data=body, headers=headers, method=method)
|
||||
try:
|
||||
with urllib.request.urlopen(req) as resp:
|
||||
return resp.status, resp.read()
|
||||
except urllib.error.HTTPError as exc:
|
||||
data = exc.read()
|
||||
return exc.code, data
|
||||
|
||||
|
||||
def parse_json_response(status: int, data: bytes, action: str) -> dict:
|
||||
try:
|
||||
parsed = json.loads(data.decode("utf-8") or "{}")
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ReleaseError(f"{action} failed ({status}): {data.decode('utf-8', 'replace')}") from exc
|
||||
if status >= 400:
|
||||
raise ReleaseError(f"{action} failed ({status}): {parsed}")
|
||||
if not isinstance(parsed, dict):
|
||||
raise ReleaseError(f"{action} returned unexpected JSON: {parsed}")
|
||||
return parsed
|
||||
|
||||
|
||||
def find_or_create_release(config: ReleaseConfig, log: Callable[[str], None]) -> int:
|
||||
repo_path = f"/api/v1/repos/{config.gitea_owner}/{config.gitea_repo}"
|
||||
tag = f"v{config.version}"
|
||||
tag_path = repo_path + "/releases/tags/" + urllib.parse.quote(tag, safe="")
|
||||
status, data = gitea_request(config, "GET", tag_path)
|
||||
if status == 200:
|
||||
release = parse_json_response(status, data, "Find release")
|
||||
release_id = release.get("id")
|
||||
if not release_id:
|
||||
raise ReleaseError(f"Existing release missing id: {release}")
|
||||
log(f"Gitea release exists: {tag} (id {release_id})")
|
||||
return int(release_id)
|
||||
if status != 404:
|
||||
parse_json_response(status, data, "Find release")
|
||||
|
||||
payload = {
|
||||
"tag_name": tag,
|
||||
"target_commitish": config.branch,
|
||||
"name": tag,
|
||||
"body": f"I Want to Heal Android build v{config.version}",
|
||||
"draft": False,
|
||||
"prerelease": False,
|
||||
}
|
||||
status, data = gitea_request(
|
||||
config,
|
||||
"POST",
|
||||
repo_path + "/releases",
|
||||
body=json.dumps(payload).encode("utf-8"),
|
||||
content_type="application/json",
|
||||
)
|
||||
release = parse_json_response(status, data, "Create release")
|
||||
release_id = release.get("id")
|
||||
if not release_id:
|
||||
raise ReleaseError(f"Created release missing id: {release}")
|
||||
log(f"Gitea release created: {tag} (id {release_id})")
|
||||
return int(release_id)
|
||||
|
||||
|
||||
def upload_release_asset(config: ReleaseConfig, log: Callable[[str], None]) -> None:
|
||||
apk_path = REPO_ROOT / f"IWantToHeal-Thor-v{config.version}.apk"
|
||||
if not apk_path.exists():
|
||||
raise ReleaseError(f"APK not found for release upload: {apk_path}")
|
||||
release_id = find_or_create_release(config, log)
|
||||
|
||||
boundary = f"----iwanttoheal{int(time.time() * 1000)}"
|
||||
header = (
|
||||
f"--{boundary}\r\n"
|
||||
f'Content-Disposition: form-data; name="attachment"; filename="{apk_path.name}"\r\n'
|
||||
"Content-Type: application/vnd.android.package-archive\r\n\r\n"
|
||||
).encode("utf-8")
|
||||
footer = f"\r\n--{boundary}--\r\n".encode("utf-8")
|
||||
body = header + apk_path.read_bytes() + footer
|
||||
asset_name = urllib.parse.quote(apk_path.name)
|
||||
path = (
|
||||
f"/api/v1/repos/{config.gitea_owner}/{config.gitea_repo}"
|
||||
f"/releases/{release_id}/assets?name={asset_name}"
|
||||
)
|
||||
status, data = gitea_request(
|
||||
config,
|
||||
"POST",
|
||||
path,
|
||||
body=body,
|
||||
content_type=f"multipart/form-data; boundary={boundary}",
|
||||
)
|
||||
if status == 409:
|
||||
raise ReleaseError(f"Release asset already exists: {apk_path.name}")
|
||||
parse_json_response(status, data, "Upload release asset")
|
||||
log(f"Gitea asset uploaded: {apk_path.name}")
|
||||
|
||||
|
||||
def update_truenas(config: ReleaseConfig, log: Callable[[str], None]) -> None:
|
||||
app_path = Path(config.truenas_path)
|
||||
if not app_path.exists():
|
||||
raise ReleaseError(f"TrueNAS path not found: {app_path}")
|
||||
run_command(["git", "pull"], cwd=app_path, env=None, log=log)
|
||||
|
||||
if config.run_truenas_backup:
|
||||
db_path = Path(config.truenas_db_path)
|
||||
if not db_path.exists():
|
||||
raise ReleaseError(f"TrueNAS database not found: {db_path}")
|
||||
stamp = time.strftime("%Y%m%d-%H%M%S")
|
||||
backup_path = db_path.with_name(f"game-before-update-{stamp}.db")
|
||||
backup_path.write_bytes(db_path.read_bytes())
|
||||
log(f"TrueNAS database backup copied: {backup_path}")
|
||||
|
||||
if config.run_restart:
|
||||
if not config.restart_command.strip():
|
||||
raise ReleaseError("Restart command is empty")
|
||||
run_command(["sh", "-lc", config.restart_command], cwd=app_path, env=None, log=log)
|
||||
|
||||
|
||||
def validate_config(config: ReleaseConfig) -> None:
|
||||
if not re.fullmatch(r"\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?", config.version):
|
||||
raise ReleaseError("Version must look like 1.1.2")
|
||||
if config.run_release and not config.token:
|
||||
raise ReleaseError("Gitea token required for release step")
|
||||
if config.run_restart and not config.run_truenas_pull:
|
||||
raise ReleaseError("Restart needs TrueNAS update step enabled")
|
||||
|
||||
|
||||
def prompt_text(label: str, default: str = "", *, secret: bool = False) -> str:
|
||||
prompt = f"{label}"
|
||||
if default:
|
||||
prompt += f" [{default}]"
|
||||
prompt += ": "
|
||||
try:
|
||||
value = getpass(prompt) if secret else input(prompt)
|
||||
except EOFError:
|
||||
value = ""
|
||||
return value.strip() or default
|
||||
|
||||
|
||||
def prompt_bool(label: str, default: bool) -> bool:
|
||||
suffix = "Y/n" if default else "y/N"
|
||||
try:
|
||||
value = input(f"{label} [{suffix}]: ").strip().lower()
|
||||
except EOFError:
|
||||
value = ""
|
||||
if not value:
|
||||
return default
|
||||
return value in {"y", "yes", "true", "1"}
|
||||
|
||||
|
||||
def run_release_steps(config: ReleaseConfig, log: Callable[[str], None]) -> None:
|
||||
if config.run_build:
|
||||
build_apk(config, log)
|
||||
if config.run_commit_push:
|
||||
commit_and_push(config, log)
|
||||
if config.run_release:
|
||||
upload_release_asset(config, log)
|
||||
if config.run_truenas_pull:
|
||||
update_truenas(config, log)
|
||||
|
||||
|
||||
def cli_main() -> int:
|
||||
print("Tkinter not available. CLI mode.")
|
||||
current_version = read_current_version()
|
||||
config = ReleaseConfig(
|
||||
version=prompt_text("Version", current_version),
|
||||
token=prompt_text("Gitea token", secret=True),
|
||||
api_base_url=prompt_text("API base URL", DEFAULT_API_BASE_URL),
|
||||
java_home=prompt_text("JAVA_HOME", DEFAULT_JAVA_HOME),
|
||||
gitea_url=prompt_text("Gitea URL", DEFAULT_GITEA_URL),
|
||||
gitea_owner=prompt_text("Gitea owner", DEFAULT_GITEA_OWNER),
|
||||
gitea_repo=prompt_text("Gitea repo", DEFAULT_GITEA_REPO),
|
||||
branch=prompt_text("Git branch", DEFAULT_BRANCH),
|
||||
truenas_path=prompt_text("TrueNAS app path", DEFAULT_TRUENAS_PATH),
|
||||
truenas_db_path=prompt_text("TrueNAS DB path", DEFAULT_DB_PATH),
|
||||
restart_command=prompt_text("Restart command"),
|
||||
run_build=prompt_bool("Build APK", True),
|
||||
run_commit_push=prompt_bool("Commit + push", True),
|
||||
run_release=prompt_bool("Create Gitea release", True),
|
||||
run_truenas_pull=prompt_bool("TrueNAS pull", True),
|
||||
run_truenas_backup=prompt_bool("Backup DB", True),
|
||||
run_restart=prompt_bool("Restart", False),
|
||||
)
|
||||
validate_config(config)
|
||||
run_release_steps(config, print)
|
||||
print("Release steps complete")
|
||||
return 0
|
||||
|
||||
|
||||
if tk is not None and ttk is not None and messagebox is not None:
|
||||
|
||||
class ReleaseApp(ttk.Frame):
|
||||
def __init__(self, root: tk.Tk) -> None:
|
||||
super().__init__(root, padding=12)
|
||||
self.root = root
|
||||
self.log_queue: queue.Queue[tuple[str, str]] = queue.Queue()
|
||||
self.worker: threading.Thread | None = None
|
||||
self.vars: dict[str, tk.Variable] = {}
|
||||
self.grid(sticky="nsew")
|
||||
root.title("I Want to Heal Release Tool")
|
||||
root.geometry("920x720")
|
||||
root.columnconfigure(0, weight=1)
|
||||
root.rowconfigure(0, weight=1)
|
||||
self.columnconfigure(1, weight=1)
|
||||
self.rowconfigure(13, weight=1)
|
||||
self._build_form()
|
||||
self.after(100, self._drain_log_queue)
|
||||
|
||||
def _var(self, name: str, value: object = "") -> tk.Variable:
|
||||
if isinstance(value, bool):
|
||||
var: tk.Variable = tk.BooleanVar(value=value)
|
||||
else:
|
||||
var = tk.StringVar(value=str(value))
|
||||
self.vars[name] = var
|
||||
return var
|
||||
|
||||
def _build_form(self) -> None:
|
||||
current_version = read_current_version()
|
||||
fields = [
|
||||
("Version", "version", current_version),
|
||||
("Gitea token", "token", ""),
|
||||
("API base URL", "api_base_url", DEFAULT_API_BASE_URL),
|
||||
("JAVA_HOME", "java_home", DEFAULT_JAVA_HOME),
|
||||
("Gitea URL", "gitea_url", DEFAULT_GITEA_URL),
|
||||
("Gitea owner", "gitea_owner", DEFAULT_GITEA_OWNER),
|
||||
("Gitea repo", "gitea_repo", DEFAULT_GITEA_REPO),
|
||||
("Git branch", "branch", DEFAULT_BRANCH),
|
||||
("TrueNAS app path", "truenas_path", DEFAULT_TRUENAS_PATH),
|
||||
("TrueNAS DB path", "truenas_db_path", DEFAULT_DB_PATH),
|
||||
("Restart command", "restart_command", ""),
|
||||
]
|
||||
for row, (label, name, value) in enumerate(fields):
|
||||
ttk.Label(self, text=label).grid(row=row, column=0, sticky="w", pady=3)
|
||||
entry = ttk.Entry(self, textvariable=self._var(name, value), show="*" if name == "token" else "")
|
||||
entry.grid(row=row, column=1, sticky="ew", pady=3)
|
||||
|
||||
checks = ttk.Frame(self)
|
||||
checks.grid(row=11, column=0, columnspan=2, sticky="ew", pady=(10, 4))
|
||||
check_data = [
|
||||
("Build APK", "run_build", True),
|
||||
("Commit + push", "run_commit_push", True),
|
||||
("Create Gitea release", "run_release", True),
|
||||
("TrueNAS pull", "run_truenas_pull", True),
|
||||
("Backup DB", "run_truenas_backup", True),
|
||||
("Restart", "run_restart", False),
|
||||
]
|
||||
for index, (text, name, value) in enumerate(check_data):
|
||||
ttk.Checkbutton(checks, text=text, variable=self._var(name, value)).grid(
|
||||
row=0, column=index, padx=(0, 14), sticky="w"
|
||||
)
|
||||
|
||||
buttons = ttk.Frame(self)
|
||||
buttons.grid(row=12, column=0, columnspan=2, sticky="ew", pady=(6, 8))
|
||||
self.run_button = ttk.Button(buttons, text="Run Selected Steps", command=self.run_release)
|
||||
self.run_button.pack(side="left")
|
||||
ttk.Button(buttons, text="Start Admin Server", command=self.start_admin).pack(side="left", padx=8)
|
||||
|
||||
self.log_text = tk.Text(self, height=18, wrap="word")
|
||||
self.log_text.grid(row=13, column=0, columnspan=2, sticky="nsew")
|
||||
scroll = ttk.Scrollbar(self, command=self.log_text.yview)
|
||||
scroll.grid(row=13, column=2, sticky="ns")
|
||||
self.log_text.configure(yscrollcommand=scroll.set)
|
||||
|
||||
def config_from_form(self) -> ReleaseConfig:
|
||||
get = lambda name: str(self.vars[name].get()).strip()
|
||||
get_bool = lambda name: bool(self.vars[name].get())
|
||||
return ReleaseConfig(
|
||||
version=get("version"),
|
||||
token=get("token"),
|
||||
api_base_url=get("api_base_url"),
|
||||
java_home=get("java_home"),
|
||||
gitea_url=get("gitea_url"),
|
||||
gitea_owner=get("gitea_owner"),
|
||||
gitea_repo=get("gitea_repo"),
|
||||
branch=get("branch"),
|
||||
truenas_path=get("truenas_path"),
|
||||
truenas_db_path=get("truenas_db_path"),
|
||||
restart_command=get("restart_command"),
|
||||
run_build=get_bool("run_build"),
|
||||
run_commit_push=get_bool("run_commit_push"),
|
||||
run_release=get_bool("run_release"),
|
||||
run_truenas_pull=get_bool("run_truenas_pull"),
|
||||
run_truenas_backup=get_bool("run_truenas_backup"),
|
||||
run_restart=get_bool("run_restart"),
|
||||
)
|
||||
|
||||
def log(self, message: str) -> None:
|
||||
self.log_queue.put(("log", message))
|
||||
|
||||
def _drain_log_queue(self) -> None:
|
||||
try:
|
||||
while True:
|
||||
kind, message = self.log_queue.get_nowait()
|
||||
if kind == "done":
|
||||
self.run_button.configure(state="normal")
|
||||
if message:
|
||||
messagebox.showinfo("Release tool", message)
|
||||
elif kind == "error":
|
||||
self.run_button.configure(state="normal")
|
||||
messagebox.showerror("Release failed", message)
|
||||
else:
|
||||
self.log_text.insert("end", message + "\n")
|
||||
self.log_text.see("end")
|
||||
except queue.Empty:
|
||||
pass
|
||||
self.after(100, self._drain_log_queue)
|
||||
|
||||
def run_release(self) -> None:
|
||||
if self.worker and self.worker.is_alive():
|
||||
return
|
||||
try:
|
||||
config = self.config_from_form()
|
||||
validate_config(config)
|
||||
except ReleaseError as exc:
|
||||
messagebox.showerror("Invalid settings", str(exc))
|
||||
return
|
||||
self.run_button.configure(state="disabled")
|
||||
self.log_text.delete("1.0", "end")
|
||||
self.worker = threading.Thread(target=self._run_release_worker, args=(config,), daemon=True)
|
||||
self.worker.start()
|
||||
|
||||
def _run_release_worker(self, config: ReleaseConfig) -> None:
|
||||
try:
|
||||
run_release_steps(config, self.log)
|
||||
self.log_queue.put(("done", "Release steps complete"))
|
||||
except Exception as exc:
|
||||
self.log_queue.put(("error", str(exc)))
|
||||
|
||||
def start_admin(self) -> None:
|
||||
if self.worker and self.worker.is_alive():
|
||||
messagebox.showerror("Busy", "Release steps are still running")
|
||||
return
|
||||
self.log_text.delete("1.0", "end")
|
||||
self.run_button.configure(state="disabled")
|
||||
self.worker = threading.Thread(target=self._start_admin_worker, daemon=True)
|
||||
self.worker.start()
|
||||
|
||||
def _start_admin_worker(self) -> None:
|
||||
try:
|
||||
self.log("Admin URL: http://127.0.0.1:4174")
|
||||
run_command(["npm", "run", "admin:start"], cwd=REPO_ROOT, env=None, log=self.log)
|
||||
self.log_queue.put(("done", "Admin server stopped"))
|
||||
except Exception as exc:
|
||||
self.log_queue.put(("error", str(exc)))
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if not GRADLE_FILE.exists():
|
||||
print(f"Missing Gradle file: {GRADLE_FILE}", file=sys.stderr)
|
||||
return 1
|
||||
if tk is None:
|
||||
return cli_main()
|
||||
root = tk.Tk()
|
||||
ReleaseApp(root)
|
||||
root.mainloop()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+166
@@ -0,0 +1,166 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
GRADLE_FILE = REPO_ROOT / "android" / "app" / "build.gradle"
|
||||
JAVA_HOME = "/Applications/Android Studio.app/Contents/jbr/Contents/Home"
|
||||
API_BASE_URL = "https://iwanttoheal.phenomrom.com"
|
||||
GITEA_URL = "https://git.whoagland.com"
|
||||
GITEA_OWNER = "phenom"
|
||||
GITEA_REPO = "i-want-to-heal"
|
||||
GITEA_TOKEN = "ed2db3fd54546e9658377d0551b3fc3961583f1d"
|
||||
BRANCH = "main"
|
||||
TRUENAS_PATH = Path("/mnt/usbssds/apps/iwanttoheal/app")
|
||||
|
||||
|
||||
def run(args: list[str], *, cwd: Path, env: dict[str, str] | None = None) -> None:
|
||||
print(f"$ {' '.join(args)}", flush=True)
|
||||
subprocess.run(args, cwd=cwd, env=env, check=True)
|
||||
|
||||
|
||||
def prompt_version() -> str:
|
||||
current = GRADLE_FILE.read_text()
|
||||
match = re.search(r'versionName\s+"([^"]+)"', current)
|
||||
current_version = match.group(1) if match else ""
|
||||
suffix = f" [{current_version}]" if current_version else ""
|
||||
version = input(f"Version{suffix}: ").strip() or current_version
|
||||
if not re.fullmatch(r"\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?", version):
|
||||
raise SystemExit("Version must look like 1.1.2")
|
||||
return version
|
||||
|
||||
|
||||
def update_gradle_version(version: str) -> None:
|
||||
text = GRADLE_FILE.read_text()
|
||||
code_match = re.search(r"versionCode\s+(\d+)", text)
|
||||
if not code_match:
|
||||
raise SystemExit(f"Could not find versionCode in {GRADLE_FILE}")
|
||||
|
||||
next_code = int(code_match.group(1)) + 1
|
||||
text = re.sub(r"versionCode\s+\d+", f"versionCode {next_code}", text, count=1)
|
||||
text = re.sub(r'versionName\s+"[^"]+"', f'versionName "{version}"', text, count=1)
|
||||
GRADLE_FILE.write_text(text)
|
||||
print(f"Android version: {version}, versionCode: {next_code}")
|
||||
|
||||
|
||||
def build_apk(version: str) -> Path:
|
||||
update_gradle_version(version)
|
||||
env = os.environ.copy()
|
||||
env["JAVA_HOME"] = JAVA_HOME
|
||||
env["PATH"] = f"{JAVA_HOME}/bin:{env.get('PATH', '')}"
|
||||
env["VITE_API_BASE_URL"] = API_BASE_URL
|
||||
|
||||
run(["npm", "run", "android:sync"], cwd=REPO_ROOT, env=env)
|
||||
run(["./gradlew", "clean", "assembleDebug"], cwd=REPO_ROOT / "android", env=env)
|
||||
|
||||
source = REPO_ROOT / "android" / "app" / "build" / "outputs" / "apk" / "debug" / "app-debug.apk"
|
||||
apk = REPO_ROOT / f"IWantToHeal-Thor-v{version}.apk"
|
||||
shutil.copy2(source, apk)
|
||||
print(f"APK: {apk}")
|
||||
return apk
|
||||
|
||||
|
||||
def commit_and_push(version: str) -> None:
|
||||
run(["git", "add", "."], cwd=REPO_ROOT)
|
||||
run(["git", "commit", "-m", f"Android build v{version}"], cwd=REPO_ROOT)
|
||||
run(["git", "push", "origin", BRANCH], cwd=REPO_ROOT)
|
||||
|
||||
|
||||
def request_json(method: str, path: str, token: str, data: dict | None = None) -> dict:
|
||||
body = json.dumps(data).encode("utf-8") if data is not None else None
|
||||
headers = {"Authorization": f"token {token}", "Content-Type": "application/json"}
|
||||
req = urllib.request.Request(GITEA_URL + path, data=body, headers=headers, method=method)
|
||||
try:
|
||||
with urllib.request.urlopen(req) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
except urllib.error.HTTPError as exc:
|
||||
detail = exc.read().decode("utf-8", "replace")
|
||||
raise SystemExit(f"Gitea {method} failed ({exc.code}): {detail}") from exc
|
||||
|
||||
|
||||
def upload_asset(path: str, token: str, apk: Path) -> dict:
|
||||
boundary = "----iwanttoheal-release-boundary"
|
||||
body = (
|
||||
f"--{boundary}\r\n"
|
||||
f'Content-Disposition: form-data; name="attachment"; filename="{apk.name}"\r\n'
|
||||
"Content-Type: application/vnd.android.package-archive\r\n\r\n"
|
||||
).encode("utf-8")
|
||||
body += apk.read_bytes()
|
||||
body += f"\r\n--{boundary}--\r\n".encode("utf-8")
|
||||
headers = {
|
||||
"Authorization": f"token {token}",
|
||||
"Content-Type": f"multipart/form-data; boundary={boundary}",
|
||||
}
|
||||
req = urllib.request.Request(GITEA_URL + path, data=body, headers=headers, method="POST")
|
||||
try:
|
||||
with urllib.request.urlopen(req) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
except urllib.error.HTTPError as exc:
|
||||
detail = exc.read().decode("utf-8", "replace")
|
||||
raise SystemExit(f"Gitea asset upload failed ({exc.code}): {detail}") from exc
|
||||
|
||||
|
||||
def create_gitea_release(version: str, apk: Path) -> None:
|
||||
token = GITEA_TOKEN.strip()
|
||||
if not token or token == "PASTE_YOUR_GITEA_TOKEN_HERE":
|
||||
raise SystemExit("Set GITEA_TOKEN near top of scripts/release_game_cli.py")
|
||||
|
||||
repo_path = f"/api/v1/repos/{GITEA_OWNER}/{GITEA_REPO}"
|
||||
release = request_json(
|
||||
"POST",
|
||||
repo_path + "/releases",
|
||||
token,
|
||||
{
|
||||
"tag_name": f"v{version}",
|
||||
"target_commitish": BRANCH,
|
||||
"name": f"v{version}",
|
||||
"body": f"I Want to Heal Android build v{version}",
|
||||
"draft": False,
|
||||
"prerelease": False,
|
||||
},
|
||||
)
|
||||
release_id = release.get("id")
|
||||
if not release_id:
|
||||
raise SystemExit(f"Gitea release missing id: {release}")
|
||||
|
||||
asset_name = urllib.parse.quote(apk.name)
|
||||
upload_asset(f"{repo_path}/releases/{release_id}/assets?name={asset_name}", token, apk)
|
||||
print(f"Gitea release uploaded: v{version}")
|
||||
|
||||
|
||||
def update_truenas() -> None:
|
||||
if TRUENAS_PATH.exists():
|
||||
run(["git", "pull"], cwd=TRUENAS_PATH)
|
||||
print("Restart app in TrueNAS UI.")
|
||||
return
|
||||
|
||||
print("TrueNAS path not mounted here. Run on TrueNAS:")
|
||||
print(f"cd {TRUENAS_PATH}")
|
||||
print("git pull")
|
||||
print("Then restart app in TrueNAS UI.")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
os.chdir(REPO_ROOT)
|
||||
version = prompt_version()
|
||||
apk = build_apk(version)
|
||||
commit_and_push(version)
|
||||
create_gitea_release(version, apk)
|
||||
update_truenas()
|
||||
print("Done.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
+370
-21
@@ -9,6 +9,12 @@ import {
|
||||
import { isIP } from 'node:net'
|
||||
import { extname, resolve, sep } from 'node:path'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import { catalogPayload } from './catalog.mjs'
|
||||
import {
|
||||
catchUpExperienceReward as catchUpExperienceRewardForTarget,
|
||||
coinDropQuantity,
|
||||
roguelikeCoinItemLevel,
|
||||
} from '../src/shared/rewardRules.mjs'
|
||||
|
||||
const databasePath = fileURLToPath(new URL('../data/game.db', import.meta.url))
|
||||
const bossImageDirectory = fileURLToPath(new URL('../data/uploads/bosses/', import.meta.url))
|
||||
@@ -26,6 +32,10 @@ const directCraftItemLevels = new Set([1, 10, 20, 25])
|
||||
const sessionCookieName = 'chronicle_session'
|
||||
const sessionLifetimeSeconds = 60 * 60 * 24 * 30
|
||||
const rateLimitBuckets = new Map()
|
||||
const pvpQueue = new Map()
|
||||
const pvpMatches = new Map()
|
||||
const pvpQueueTtlMs = 15 * 1000
|
||||
const pvpMatchTtlMs = 60 * 60 * 1000
|
||||
|
||||
function sendJson(response, status, body, headers = {}) {
|
||||
response.statusCode = status
|
||||
@@ -246,10 +256,7 @@ function catchUpExperienceReward(database, accountId, characterId, baseReward, c
|
||||
FROM level_progression
|
||||
WHERE level = ?
|
||||
`).get(targetLevel)?.experienceRequired ?? currentExperience
|
||||
const gap = Math.max(0, targetExperience - currentExperience)
|
||||
if (gap <= 0) return baseReward
|
||||
const doubledBase = Math.min(baseReward, Math.ceil(gap / 2))
|
||||
return doubledBase * 2 + (baseReward - doubledBase)
|
||||
return catchUpExperienceRewardForTarget(baseReward, currentExperience, targetExperience)
|
||||
}
|
||||
|
||||
function normalizeUsername(value) {
|
||||
@@ -348,10 +355,13 @@ function currentSession(database, request) {
|
||||
accounts.id AS accountId,
|
||||
accounts.username,
|
||||
characters.id AS characterId,
|
||||
characters.class_id AS classId
|
||||
characters.class_id AS classId,
|
||||
characters.name AS characterName,
|
||||
classes.name AS className
|
||||
FROM sessions
|
||||
JOIN accounts ON accounts.id = sessions.account_id
|
||||
JOIN characters ON characters.id = sessions.active_character_id
|
||||
JOIN classes ON classes.id = characters.class_id
|
||||
WHERE sessions.token_hash = ?
|
||||
AND sessions.expires_at > CURRENT_TIMESTAMP
|
||||
`).get(tokenHash(token)) ?? null
|
||||
@@ -1337,17 +1347,6 @@ function formatLootRoll(database, context, record, dropChance) {
|
||||
}
|
||||
}
|
||||
|
||||
function coinDropQuantity() {
|
||||
const roll = Math.random()
|
||||
if (roll < 0.15) return 3
|
||||
if (roll < 0.5) return 2
|
||||
return 1
|
||||
}
|
||||
|
||||
function roguelikeCoinItemLevel(stage) {
|
||||
return Math.min(25, 5 + Math.max(0, Math.floor(stage / 5)) * 5)
|
||||
}
|
||||
|
||||
function awardRoguelikeCoin(database, characterId, sourceEncounterId, stage) {
|
||||
if (!sourceEncounterId || !stage) return null
|
||||
const coin = database.prepare(`
|
||||
@@ -2299,7 +2298,18 @@ function completeRoguelike(database, characterId, accountId, runMetrics) {
|
||||
const bossesCleared = Number(runMetrics?.bossesCleared ?? Math.floor(encountersCleared / 3))
|
||||
const experienceMode = runMetrics?.experienceMode === 'pvp-boss-quarter-level'
|
||||
? 'pvp-boss-quarter-level'
|
||||
: 'default'
|
||||
: runMetrics?.experienceMode === 'pvp-fight-twelfth-level'
|
||||
? 'pvp-fight-twelfth-level'
|
||||
: runMetrics?.experienceMode === 'pvp-match-win-half-level'
|
||||
? 'pvp-match-win-half-level'
|
||||
: runMetrics?.experienceMode === 'pvp-stadium-round-win-quarter-level'
|
||||
? 'pvp-stadium-round-win-quarter-level'
|
||||
: runMetrics?.experienceMode === 'pvp-stadium-round-loss-tenth-level'
|
||||
? 'pvp-stadium-round-loss-tenth-level'
|
||||
: runMetrics?.experienceMode === 'pvp-stadium-match-half-level'
|
||||
? 'pvp-stadium-match-half-level'
|
||||
: 'default'
|
||||
const fightsCleared = Number(runMetrics?.fightsCleared ?? encountersCleared)
|
||||
const resourceSpent = Number(runMetrics?.resourceSpent)
|
||||
const durationSeconds = Number(runMetrics?.durationSeconds)
|
||||
if (!Number.isInteger(dungeonId) || dungeonId < 1) {
|
||||
@@ -2314,6 +2324,9 @@ function completeRoguelike(database, characterId, accountId, runMetrics) {
|
||||
if (!Number.isInteger(bossesCleared) || bossesCleared < 0 || bossesCleared > 100000) {
|
||||
throw new Error('The roguelike boss total is invalid.')
|
||||
}
|
||||
if (!Number.isInteger(fightsCleared) || fightsCleared < 0 || fightsCleared > 100000) {
|
||||
throw new Error('The roguelike fight total is invalid.')
|
||||
}
|
||||
if (!Number.isInteger(resourceSpent) || resourceSpent < 0 || resourceSpent > 100000) {
|
||||
throw new Error('The run resource total is invalid.')
|
||||
}
|
||||
@@ -2366,14 +2379,15 @@ function completeRoguelike(database, characterId, accountId, runMetrics) {
|
||||
`).get(maxLevel).experienceRequired
|
||||
let newExperience = character.experience
|
||||
let newLevel = character.level
|
||||
if (experienceMode === 'pvp-boss-quarter-level') {
|
||||
if (experienceMode === 'pvp-boss-quarter-level' || experienceMode === 'pvp-fight-twelfth-level') {
|
||||
const catchUpTargetLevel = database.prepare(`
|
||||
SELECT COALESCE(MAX(level), 0) AS level
|
||||
FROM characters
|
||||
WHERE account_id = ?
|
||||
AND id != ?
|
||||
`).get(accountId, characterId).level
|
||||
for (let bossIndex = 0; bossIndex < bossesCleared && newExperience < maxExperience; bossIndex += 1) {
|
||||
const rewardUnits = experienceMode === 'pvp-boss-quarter-level' ? bossesCleared : fightsCleared
|
||||
for (let rewardIndex = 0; rewardIndex < rewardUnits && newExperience < maxExperience; rewardIndex += 1) {
|
||||
const currentLevelFloor = database.prepare(`
|
||||
SELECT experience_required AS experienceRequired
|
||||
FROM level_progression
|
||||
@@ -2387,7 +2401,9 @@ function completeRoguelike(database, characterId, accountId, runMetrics) {
|
||||
WHERE level = ?
|
||||
`).get(newLevel + 1).experienceRequired
|
||||
const levelBand = Math.max(1, nextLevelExperience - currentLevelFloor)
|
||||
const rewardRate = catchUpTargetLevel > newLevel ? 0.5 : 0.25
|
||||
const rewardRate = experienceMode === 'pvp-boss-quarter-level'
|
||||
? (catchUpTargetLevel > newLevel ? 0.5 : 0.25)
|
||||
: (catchUpTargetLevel > newLevel ? 1 / 6 : 1 / 12)
|
||||
newExperience = Math.min(maxExperience, newExperience + Math.round(levelBand * rewardRate))
|
||||
newLevel = database.prepare(`
|
||||
SELECT MAX(level) AS level
|
||||
@@ -2395,6 +2411,36 @@ function completeRoguelike(database, characterId, accountId, runMetrics) {
|
||||
WHERE experience_required <= ?
|
||||
`).get(newExperience).level
|
||||
}
|
||||
} else if (
|
||||
experienceMode === 'pvp-match-win-half-level'
|
||||
|| experienceMode === 'pvp-stadium-round-win-quarter-level'
|
||||
|| experienceMode === 'pvp-stadium-round-loss-tenth-level'
|
||||
|| experienceMode === 'pvp-stadium-match-half-level'
|
||||
) {
|
||||
const currentLevelFloor = database.prepare(`
|
||||
SELECT experience_required AS experienceRequired
|
||||
FROM level_progression
|
||||
WHERE level = ?
|
||||
`).get(newLevel).experienceRequired
|
||||
const nextLevelExperience = newLevel >= maxLevel
|
||||
? maxExperience
|
||||
: database.prepare(`
|
||||
SELECT experience_required AS experienceRequired
|
||||
FROM level_progression
|
||||
WHERE level = ?
|
||||
`).get(newLevel + 1).experienceRequired
|
||||
const levelBand = Math.max(1, nextLevelExperience - currentLevelFloor)
|
||||
const rewardRate = experienceMode === 'pvp-stadium-round-win-quarter-level'
|
||||
? 0.25
|
||||
: experienceMode === 'pvp-stadium-round-loss-tenth-level'
|
||||
? 0.1
|
||||
: 0.5
|
||||
newExperience = Math.min(maxExperience, newExperience + Math.round(levelBand * rewardRate))
|
||||
newLevel = database.prepare(`
|
||||
SELECT MAX(level) AS level
|
||||
FROM level_progression
|
||||
WHERE experience_required <= ?
|
||||
`).get(newExperience).level
|
||||
} else {
|
||||
const baseExperienceReward = Math.round(
|
||||
dungeon.experienceReward * dungeon.experienceMultiplier * (encountersCleared / 3),
|
||||
@@ -2537,6 +2583,261 @@ 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' && value !== 'stadium') {
|
||||
throw new Error('The PvP content type is invalid.')
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function validatePvpStartStage(value) {
|
||||
const startStage = Number(value)
|
||||
if (!Number.isInteger(startStage) || startStage < 1 || startStage > 1000) {
|
||||
throw new Error('The PvP start stage is invalid.')
|
||||
}
|
||||
return startStage
|
||||
}
|
||||
|
||||
function pvpPlayerInfo(session) {
|
||||
return {
|
||||
accountId: session.accountId,
|
||||
characterId: session.characterId,
|
||||
characterName: session.characterName,
|
||||
className: session.className,
|
||||
}
|
||||
}
|
||||
|
||||
function pvpSnapshot(match) {
|
||||
return {
|
||||
id: match.id,
|
||||
contentType: match.contentType,
|
||||
startStage: match.startStage,
|
||||
createdAt: match.createdAt,
|
||||
players: match.players,
|
||||
states: match.states,
|
||||
statuses: match.statuses,
|
||||
progress: match.progress,
|
||||
upgradeChoices: match.upgradeChoices,
|
||||
rematchRequests: match.rematchRequests,
|
||||
updatedAt: match.updatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
function createPvpMatch(contentType, startStage, players, now = Date.now()) {
|
||||
const matchId = randomBytes(12).toString('base64url')
|
||||
const match = {
|
||||
id: matchId,
|
||||
contentType,
|
||||
startStage,
|
||||
createdAt: now,
|
||||
players,
|
||||
states: {},
|
||||
statuses: {},
|
||||
progress: {},
|
||||
upgradeChoices: {},
|
||||
rematchRequests: {},
|
||||
updatedAt: now,
|
||||
}
|
||||
pvpMatches.set(matchId, match)
|
||||
return match
|
||||
}
|
||||
|
||||
function joinPvpQueue(session, payload) {
|
||||
const now = Date.now()
|
||||
cleanupPvpMemory(now)
|
||||
const contentType = validatePvpContentType(payload.contentType)
|
||||
const startStage = validatePvpStartStage(payload.startStage)
|
||||
const existingTicket = [...pvpQueue.values()].find((ticket) =>
|
||||
ticket.accountId === session.accountId
|
||||
&& ticket.characterId === session.characterId
|
||||
&& ticket.contentType === contentType
|
||||
&& ticket.startStage === startStage
|
||||
)
|
||||
if (existingTicket?.matchId) {
|
||||
const match = pvpMatches.get(existingTicket.matchId)
|
||||
if (match) {
|
||||
const side = match.players.a.accountId === session.accountId ? 'a' : 'b'
|
||||
return { ticketId: existingTicket.id, status: 'matched', side, match: pvpSnapshot(match) }
|
||||
}
|
||||
}
|
||||
|
||||
const opponent = [...pvpQueue.values()]
|
||||
.filter((ticket) =>
|
||||
!ticket.matchId
|
||||
&& ticket.contentType === contentType
|
||||
&& ticket.startStage === startStage
|
||||
&& ticket.accountId !== session.accountId
|
||||
)
|
||||
.sort((left, right) => left.createdAt - right.createdAt)[0]
|
||||
const player = pvpPlayerInfo(session)
|
||||
if (opponent) {
|
||||
const match = createPvpMatch(contentType, startStage, {
|
||||
a: { side: 'a', ...opponent.player },
|
||||
b: { side: 'b', ...player },
|
||||
}, now)
|
||||
opponent.matchId = match.id
|
||||
opponent.updatedAt = now
|
||||
const ticketId = randomBytes(12).toString('base64url')
|
||||
pvpQueue.set(ticketId, {
|
||||
id: ticketId,
|
||||
accountId: session.accountId,
|
||||
characterId: session.characterId,
|
||||
contentType,
|
||||
startStage,
|
||||
player,
|
||||
matchId: match.id,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
return { ticketId, status: 'matched', side: 'b', match: pvpSnapshot(match) }
|
||||
}
|
||||
|
||||
if (existingTicket) {
|
||||
existingTicket.updatedAt = now
|
||||
return { ticketId: existingTicket.id, status: 'waiting' }
|
||||
}
|
||||
|
||||
const ticketId = randomBytes(12).toString('base64url')
|
||||
pvpQueue.set(ticketId, {
|
||||
id: ticketId,
|
||||
accountId: session.accountId,
|
||||
characterId: session.characterId,
|
||||
contentType,
|
||||
startStage,
|
||||
player,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
return { ticketId, status: 'waiting' }
|
||||
}
|
||||
|
||||
function checkPvpQueue(session, ticketId) {
|
||||
cleanupPvpMemory()
|
||||
const ticket = pvpQueue.get(ticketId)
|
||||
if (!ticket || ticket.accountId !== session.accountId) {
|
||||
const error = new Error('PvP queue ticket not found.')
|
||||
error.status = 404
|
||||
throw error
|
||||
}
|
||||
ticket.updatedAt = Date.now()
|
||||
if (!ticket.matchId) return { ticketId, status: 'waiting' }
|
||||
const match = pvpMatches.get(ticket.matchId)
|
||||
if (!match) return { ticketId, status: 'waiting' }
|
||||
const side = match.players.a.accountId === session.accountId ? 'a' : 'b'
|
||||
return { ticketId, status: 'matched', side, match: pvpSnapshot(match) }
|
||||
}
|
||||
|
||||
function cancelPvpQueue(session, ticketId) {
|
||||
const ticket = pvpQueue.get(ticketId)
|
||||
if (ticket && ticket.accountId === session.accountId && !ticket.matchId) {
|
||||
pvpQueue.delete(ticketId)
|
||||
}
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
function requirePvpMatchForSession(session, matchId) {
|
||||
cleanupPvpMemory()
|
||||
const match = pvpMatches.get(matchId)
|
||||
if (!match) {
|
||||
const error = new Error('PvP match not found.')
|
||||
error.status = 404
|
||||
throw error
|
||||
}
|
||||
const side = match.players.a.accountId === session.accountId ? 'a'
|
||||
: match.players.b.accountId === session.accountId ? 'b'
|
||||
: null
|
||||
if (!side) {
|
||||
const error = new Error('That PvP match belongs to another account.')
|
||||
error.status = 403
|
||||
throw error
|
||||
}
|
||||
return { match, side }
|
||||
}
|
||||
|
||||
function updatePvpMatchState(session, matchId, payload) {
|
||||
const { match, side } = requirePvpMatchForSession(session, matchId)
|
||||
const status = ['playing', 'upgrade-choice', 'shop', '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)),
|
||||
}
|
||||
const currentProgress = match.progress[side]
|
||||
if (
|
||||
currentProgress
|
||||
&& (
|
||||
progress.stage < currentProgress.stage
|
||||
|| (
|
||||
progress.stage === currentProgress.stage
|
||||
&& progress.encounterIndex < currentProgress.encounterIndex
|
||||
)
|
||||
)
|
||||
) {
|
||||
return pvpSnapshot(match)
|
||||
}
|
||||
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 ?? ''),
|
||||
purchases: Array.isArray(payload.purchases) ? payload.purchases.map((purchase) => String(purchase)) : [],
|
||||
shopReady: Boolean(payload.shopReady),
|
||||
}
|
||||
match.updatedAt = Date.now()
|
||||
return pvpSnapshot(match)
|
||||
}
|
||||
|
||||
function requestPvpRematch(session, matchId) {
|
||||
const { match, side } = requirePvpMatchForSession(session, matchId)
|
||||
if (match.nextMatchId) {
|
||||
const nextMatch = pvpMatches.get(match.nextMatchId)
|
||||
if (nextMatch) return { status: 'matched', side, match: pvpSnapshot(nextMatch) }
|
||||
}
|
||||
match.rematchRequests = match.rematchRequests ?? {}
|
||||
match.rematchRequests[side] = true
|
||||
match.updatedAt = Date.now()
|
||||
const opponentSide = side === 'a' ? 'b' : 'a'
|
||||
if (!match.rematchRequests[opponentSide]) {
|
||||
return { status: 'waiting', match: pvpSnapshot(match), side }
|
||||
}
|
||||
const nextMatch = createPvpMatch(
|
||||
match.contentType,
|
||||
match.startStage,
|
||||
{
|
||||
a: match.players.a,
|
||||
b: match.players.b,
|
||||
},
|
||||
Date.now(),
|
||||
)
|
||||
match.nextMatchId = nextMatch.id
|
||||
match.updatedAt = Date.now()
|
||||
return { status: 'matched', side, match: pvpSnapshot(nextMatch) }
|
||||
}
|
||||
|
||||
export function gameApiPlugin() {
|
||||
return {
|
||||
name: 'ashen-halls-game-api',
|
||||
@@ -2687,7 +2988,7 @@ export async function handleApiRequest(request, response, next) {
|
||||
|
||||
try {
|
||||
const ip = requestIp(request)
|
||||
consumeRateLimit(`api:${ip}`, 240, 60 * 1000)
|
||||
consumeRateLimit(`api:${ip}`, 900, 60 * 1000)
|
||||
database.prepare(`
|
||||
DELETE FROM sessions WHERE expires_at <= CURRENT_TIMESTAMP
|
||||
`).run()
|
||||
@@ -2696,6 +2997,11 @@ export async function handleApiRequest(request, response, next) {
|
||||
return
|
||||
}
|
||||
|
||||
if (request.url === '/api/catalog' && request.method === 'GET') {
|
||||
sendJson(response, 200, catalogPayload(getProfile(database, 1)))
|
||||
return
|
||||
}
|
||||
|
||||
const session = requireSession(database, request)
|
||||
|
||||
if (request.url === '/api/profile/sync-save' && request.method === 'GET') {
|
||||
@@ -2721,6 +3027,49 @@ export async function handleApiRequest(request, response, next) {
|
||||
return
|
||||
}
|
||||
|
||||
if (request.url === '/api/pvp/queue' && request.method === 'POST') {
|
||||
const payload = await readJson(request)
|
||||
sendJson(response, 200, joinPvpQueue(session, payload))
|
||||
return
|
||||
}
|
||||
|
||||
const pvpQueueTicket = request.url.match(/^\/api\/pvp\/queue\/([A-Za-z0-9_-]+)$/)
|
||||
if (pvpQueueTicket && request.method === 'GET') {
|
||||
sendJson(response, 200, checkPvpQueue(session, pvpQueueTicket[1]))
|
||||
return
|
||||
}
|
||||
|
||||
if (pvpQueueTicket && request.method === 'DELETE') {
|
||||
sendJson(response, 200, cancelPvpQueue(session, pvpQueueTicket[1]))
|
||||
return
|
||||
}
|
||||
|
||||
const pvpMatchState = request.url.match(/^\/api\/pvp\/matches\/([A-Za-z0-9_-]+)\/state$/)
|
||||
if (pvpMatchState && request.method === 'POST') {
|
||||
const payload = await readJson(request, 128 * 1024)
|
||||
sendJson(response, 200, updatePvpMatchState(session, pvpMatchState[1], payload))
|
||||
return
|
||||
}
|
||||
|
||||
const pvpUpgradeChoice = request.url.match(/^\/api\/pvp\/matches\/([A-Za-z0-9_-]+)\/upgrade-choice$/)
|
||||
if (pvpUpgradeChoice && request.method === 'POST') {
|
||||
const payload = await readJson(request)
|
||||
sendJson(response, 200, submitPvpUpgradeChoice(session, pvpUpgradeChoice[1], payload))
|
||||
return
|
||||
}
|
||||
|
||||
const pvpRematch = request.url.match(/^\/api\/pvp\/matches\/([A-Za-z0-9_-]+)\/rematch$/)
|
||||
if (pvpRematch && request.method === 'POST') {
|
||||
sendJson(response, 200, requestPvpRematch(session, pvpRematch[1]))
|
||||
return
|
||||
}
|
||||
|
||||
const pvpMatch = request.url.match(/^\/api\/pvp\/matches\/([A-Za-z0-9_-]+)$/)
|
||||
if (pvpMatch && request.method === 'GET') {
|
||||
sendJson(response, 200, pvpSnapshot(requirePvpMatchForSession(session, pvpMatch[1]).match))
|
||||
return
|
||||
}
|
||||
|
||||
const dungeonCompletion = request.url.match(/^\/api\/dungeons\/(\d+)\/complete$/)
|
||||
if (dungeonCompletion && request.method === 'POST') {
|
||||
const payload = await readJson(request)
|
||||
|
||||
+583
-48
@@ -18,6 +18,14 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
}
|
||||
|
||||
button {
|
||||
font: inherit;
|
||||
}
|
||||
@@ -618,9 +626,11 @@ textarea:focus-visible,
|
||||
.dual-top-member {
|
||||
background: var(--panel-light);
|
||||
border: 2px solid #0a0b0e;
|
||||
contain: layout paint style;
|
||||
min-height: 120px;
|
||||
outline: 2px solid #3a3944;
|
||||
padding: 14px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.dual-top-member.selected {
|
||||
@@ -773,10 +783,14 @@ textarea:focus-visible,
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
height: calc(100dvh - 20px);
|
||||
grid-template-rows: auto 1fr;
|
||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.dual-top-main.stadium-dual-top {
|
||||
grid-template-rows: auto auto minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.dual-top-main .dual-top-enemy,
|
||||
.dual-top-main .dual-top-party,
|
||||
.dual-top-main .dual-top-log {
|
||||
@@ -803,6 +817,84 @@ textarea:focus-visible,
|
||||
outline-color: var(--gold);
|
||||
}
|
||||
|
||||
.dual-top-spell-strip {
|
||||
align-items: center;
|
||||
background: var(--panel);
|
||||
border: 3px solid #0c0d11;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
grid-template-columns: repeat(6, 54px) minmax(180px, 1fr);
|
||||
min-height: 64px;
|
||||
outline: 2px solid var(--edge);
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.dual-top-spell {
|
||||
align-items: center;
|
||||
background: #20232c;
|
||||
border: 2px solid #08090c;
|
||||
color: var(--ink);
|
||||
display: flex;
|
||||
height: 48px;
|
||||
justify-content: center;
|
||||
min-width: 0;
|
||||
outline: 2px solid #4d4c58;
|
||||
padding: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.dual-top-spell:not(:disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dual-top-spell:disabled {
|
||||
opacity: 0.62;
|
||||
}
|
||||
|
||||
.dual-top-spell .spell-icon {
|
||||
font-size: 20px;
|
||||
height: 32px;
|
||||
margin: 0;
|
||||
width: 32px;
|
||||
}
|
||||
|
||||
.dual-top-spell > i {
|
||||
background: rgba(0, 0, 0, 0.58);
|
||||
bottom: 0;
|
||||
display: block;
|
||||
left: 0;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.dual-top-spell > small {
|
||||
color: #fff4a8;
|
||||
font-family: 'Press Start 2P', monospace;
|
||||
font-size: 10px;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.dual-top-resource {
|
||||
align-self: center;
|
||||
color: #82bfff;
|
||||
font-family: 'Press Start 2P', monospace;
|
||||
font-size: 8px;
|
||||
justify-self: end;
|
||||
min-width: 220px;
|
||||
width: min(280px, 100%);
|
||||
}
|
||||
|
||||
.dual-top-resource strong {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.dual-top-resource .bar {
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.dual-bottom-display {
|
||||
background:
|
||||
linear-gradient(rgba(8, 9, 12, 0.91), rgba(8, 9, 12, 0.91)),
|
||||
@@ -815,6 +907,10 @@ textarea:focus-visible,
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.pvp-opponent-bottom-display {
|
||||
grid-template-rows: auto auto minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.dual-controls-header,
|
||||
.dual-controls-resource,
|
||||
.dual-controls-targets,
|
||||
@@ -837,6 +933,110 @@ textarea:focus-visible,
|
||||
font-size: clamp(14px, 2.2vw, 23px);
|
||||
}
|
||||
|
||||
.dual-controls-header small {
|
||||
color: var(--muted);
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.dual-opponent-progress,
|
||||
.dual-opponent-effects {
|
||||
background: var(--panel);
|
||||
border: 3px solid #0c0d11;
|
||||
outline: 2px solid var(--edge);
|
||||
}
|
||||
|
||||
.dual-opponent-progress {
|
||||
align-items: center;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
grid-template-columns: minmax(130px, 0.45fr) minmax(0, 1fr);
|
||||
padding: 10px 14px;
|
||||
}
|
||||
|
||||
.dual-opponent-progress strong {
|
||||
color: var(--ink);
|
||||
font-size: 19px;
|
||||
}
|
||||
|
||||
.dual-opponent-progress .bar {
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.dual-opponent-party-grid {
|
||||
background: var(--panel);
|
||||
border: 3px solid #0c0d11;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
min-height: 0;
|
||||
outline: 2px solid var(--edge);
|
||||
overflow: hidden;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.dual-opponent-party-grid.raid {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.dual-opponent-member {
|
||||
background: var(--panel-light);
|
||||
border: 2px solid #0a0b0e;
|
||||
contain: layout paint style;
|
||||
min-width: 0;
|
||||
outline: 2px solid #3a3944;
|
||||
padding: 9px;
|
||||
}
|
||||
|
||||
.dual-opponent-member.dead {
|
||||
filter: grayscale(1);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.dual-opponent-member .member-header {
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.dual-opponent-member .member-header strong {
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.dual-opponent-member .member-header small {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.dual-opponent-member .bar {
|
||||
height: 14px;
|
||||
margin-top: 7px;
|
||||
}
|
||||
|
||||
.dual-opponent-party-grid.raid .dual-opponent-member {
|
||||
padding: 7px;
|
||||
}
|
||||
|
||||
.dual-opponent-party-grid.raid .member-header strong {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.dual-opponent-party-grid.raid .member-header small {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dual-opponent-party-grid.raid .dual-opponent-member .bar {
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.dual-opponent-effects {
|
||||
color: var(--muted);
|
||||
display: grid;
|
||||
font-size: 14px;
|
||||
gap: 8px;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.dual-controls-progress {
|
||||
color: var(--muted);
|
||||
font-family: 'Press Start 2P', monospace;
|
||||
@@ -949,6 +1149,10 @@ textarea:focus-visible,
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.dual-controls-header small {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.dual-controls-progress {
|
||||
font-size: 6px;
|
||||
}
|
||||
@@ -1022,6 +1226,67 @@ textarea:focus-visible,
|
||||
.dual-controls-spells .spell small {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.pvp-opponent-bottom-display {
|
||||
grid-template-rows: auto auto minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.dual-opponent-progress {
|
||||
border-width: 2px;
|
||||
gap: 8px;
|
||||
grid-template-columns: minmax(100px, 0.45fr) minmax(0, 1fr);
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
.dual-opponent-progress .eyebrow {
|
||||
font-size: 6px;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.dual-opponent-progress strong {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.dual-opponent-progress .bar {
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.dual-opponent-party-grid {
|
||||
border-width: 2px;
|
||||
gap: 6px;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.dual-opponent-party-grid.raid {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.dual-opponent-member {
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.dual-opponent-member .member-header strong {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.dual-opponent-member .member-header small {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.dual-opponent-member .bar {
|
||||
height: 10px;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.dual-opponent-member .member-effects {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dual-opponent-effects {
|
||||
border-width: 2px;
|
||||
font-size: 11px;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.dual-bottom-waiting {
|
||||
@@ -1685,7 +1950,7 @@ h2 {
|
||||
.equipment-screen .crafting-panel,
|
||||
.talent-screen .talent-tree,
|
||||
.talent-screen .spell-effect-layout {
|
||||
flex: 1;
|
||||
flex: 0 0 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
@@ -1712,7 +1977,8 @@ h2 {
|
||||
.customize-screen > .embedded-screen {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.customize-screen .loadout-editor {
|
||||
@@ -3519,6 +3785,7 @@ h2 {
|
||||
|
||||
.spell-effect-layout {
|
||||
display: grid;
|
||||
flex: 0 0 auto;
|
||||
gap: 14px;
|
||||
grid-template-columns: 220px minmax(0, 1fr);
|
||||
margin-top: 17px;
|
||||
@@ -3537,6 +3804,7 @@ h2 {
|
||||
}
|
||||
|
||||
.effect-slots-panel {
|
||||
align-content: start;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
grid-auto-rows: minmax(76px, auto);
|
||||
@@ -3640,12 +3908,12 @@ h2 {
|
||||
.effect-pool {
|
||||
align-content: start;
|
||||
display: grid;
|
||||
flex: 1;
|
||||
flex: 0 0 auto;
|
||||
gap: 10px;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
grid-template-rows: repeat(2, 62px);
|
||||
margin-top: 12px;
|
||||
min-height: 0;
|
||||
min-height: 134px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -4703,7 +4971,7 @@ h2 {
|
||||
.customize-tabs {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
@@ -5026,6 +5294,7 @@ h2 {
|
||||
display: block;
|
||||
height: 100%;
|
||||
transition: width 180ms linear;
|
||||
will-change: width;
|
||||
}
|
||||
|
||||
.enemy-health {
|
||||
@@ -5101,6 +5370,7 @@ h2 {
|
||||
.party-member {
|
||||
background: var(--panel-light);
|
||||
border: 2px solid #0a0b0e;
|
||||
contain: layout style;
|
||||
color: var(--ink);
|
||||
cursor: pointer;
|
||||
min-height: 92px;
|
||||
@@ -5252,22 +5522,6 @@ h2 {
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.member-health .health-text {
|
||||
color: var(--ink);
|
||||
font-family: 'Press Start 2P', monospace;
|
||||
font-size: 7px;
|
||||
font-style: normal;
|
||||
left: 50%;
|
||||
line-height: 1;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
text-shadow: 1px 1px #08090c;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
white-space: nowrap;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.raid-party-grid .party-member {
|
||||
min-height: 66px;
|
||||
padding: 7px;
|
||||
@@ -5302,6 +5556,7 @@ h2 {
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.floating-heal {
|
||||
@@ -5314,6 +5569,7 @@ h2 {
|
||||
text-shadow: 1px 1px #102016;
|
||||
top: 28px;
|
||||
transform: translateX(-50%);
|
||||
will-change: opacity, transform;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -5516,6 +5772,7 @@ h2 {
|
||||
.spell {
|
||||
background: var(--panel-light);
|
||||
border: 2px solid #090a0d;
|
||||
contain: layout paint style;
|
||||
color: var(--ink);
|
||||
cursor: pointer;
|
||||
min-height: 112px;
|
||||
@@ -5649,6 +5906,33 @@ h2 {
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.pvp-round-countdown {
|
||||
align-items: center;
|
||||
background: rgba(5, 5, 8, 0.55);
|
||||
display: flex;
|
||||
inset: 0;
|
||||
justify-content: center;
|
||||
position: fixed;
|
||||
z-index: 9;
|
||||
}
|
||||
|
||||
.pvp-round-countdown > div {
|
||||
background: var(--panel);
|
||||
border: 3px solid #0b0c0f;
|
||||
box-shadow: 8px 8px 0 #050507;
|
||||
min-width: 220px;
|
||||
outline: 2px solid var(--gold);
|
||||
padding: 28px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.pvp-round-countdown h2 {
|
||||
color: var(--gold);
|
||||
font-size: clamp(48px, 8vw, 92px);
|
||||
line-height: 1;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.result-screen > div,
|
||||
.pause-screen > div {
|
||||
background: var(--panel);
|
||||
@@ -5848,27 +6132,17 @@ h2 {
|
||||
.pvp-board {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(210px, 0.68fr) minmax(0, 1fr);
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
grid-template-rows: minmax(0, 1fr) auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.pvp-side,
|
||||
.pvp-middle-panel {
|
||||
.pvp-side {
|
||||
gap: 8px;
|
||||
min-height: 0;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.pvp-vertical-spell-bar,
|
||||
.pvp-vertical-spell-bar.six-slots {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.pvp-vertical-spell-bar .spell {
|
||||
min-height: 58px;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.pvp-screen-tools {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
@@ -5879,18 +6153,41 @@ h2 {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.pvp-resource-wrap {
|
||||
color: #82bfff;
|
||||
min-width: 150px;
|
||||
text-align: right;
|
||||
width: min(170px, 100%);
|
||||
.pvp-side-bars {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
min-width: min(320px, 45%);
|
||||
width: min(360px, 48%);
|
||||
}
|
||||
|
||||
.pvp-clear-wrap,
|
||||
.pvp-resource-wrap {
|
||||
color: var(--muted);
|
||||
font-family: 'Press Start 2P', monospace;
|
||||
font-size: 7px;
|
||||
text-align: right;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.pvp-clear-wrap > span,
|
||||
.pvp-resource-wrap > span {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.pvp-clear-wrap .bar,
|
||||
.pvp-resource-wrap .bar {
|
||||
height: 13px;
|
||||
}
|
||||
|
||||
.pvp-clear-wrap {
|
||||
color: #ff8d9a;
|
||||
}
|
||||
|
||||
.pvp-resource-wrap {
|
||||
color: #82bfff;
|
||||
}
|
||||
|
||||
.pvp-side .party-member,
|
||||
.pvp-side .party-member > div,
|
||||
.pvp-side .party-member > small {
|
||||
@@ -5908,7 +6205,7 @@ h2 {
|
||||
}
|
||||
|
||||
.pvp-side .pvp-party-grid.raid .party-member {
|
||||
min-height: 62px;
|
||||
min-height: 96px;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
@@ -5945,6 +6242,29 @@ h2 {
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.pvp-side .member-health {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.pvp-side .member-health .health-text {
|
||||
align-items: center;
|
||||
color: #fff3c7;
|
||||
display: flex;
|
||||
font-family: 'Press Start 2P', monospace;
|
||||
font-size: 7px;
|
||||
font-style: normal;
|
||||
inset: 0;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
text-shadow: 1px 1px 0 #08090c, -1px -1px 0 #08090c;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.pvp-side .party-member .member-header small {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.pvp-side .member-effects {
|
||||
margin-top: 4px;
|
||||
}
|
||||
@@ -5963,26 +6283,61 @@ h2 {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.pvp-middle-panel .encounter-header h2 {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.pvp-middle-panel .encounter-header small,
|
||||
.pvp-enemy-race small {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.pvp-middle-panel .roguelike-upgrade-list,
|
||||
.pvp-side .roguelike-upgrade-list {
|
||||
font-size: 12px;
|
||||
line-height: 1.1;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.pvp-bottom-spell-bar {
|
||||
background: var(--panel);
|
||||
border: 3px solid #0c0d11;
|
||||
box-shadow: 4px 4px 0 #08090c;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
grid-column: 1 / -1;
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
outline: 2px solid var(--edge);
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.pvp-bottom-spell-bar .spell {
|
||||
align-items: center;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
grid-template-columns: auto auto minmax(0, 1fr) auto;
|
||||
min-height: 58px;
|
||||
padding: 7px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.pvp-bottom-spell-bar .spell-icon {
|
||||
height: 34px;
|
||||
margin: 0;
|
||||
width: 34px;
|
||||
}
|
||||
|
||||
.pvp-bottom-spell-bar .spell strong {
|
||||
font-size: 13px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pvp-bottom-spell-bar .spell small {
|
||||
font-size: 13px;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pvp-choice-columns {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
@@ -6017,6 +6372,29 @@ h2 {
|
||||
margin-top: 8px !important;
|
||||
}
|
||||
|
||||
.pvp-upgrade-header {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.pvp-upgrade-header h2 {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.pvp-upgrade-header > strong {
|
||||
color: var(--gold);
|
||||
font-family: 'Press Start 2P', monospace;
|
||||
font-size: 16px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pvp-upgrade-header > strong.danger {
|
||||
color: #ff8190;
|
||||
}
|
||||
|
||||
.pvp-upgrade-dialog .pvp-choice-columns {
|
||||
gap: 10px;
|
||||
margin-top: 0;
|
||||
@@ -6039,6 +6417,150 @@ h2 {
|
||||
box-shadow: inset 0 0 0 2px #6e5727;
|
||||
}
|
||||
|
||||
.stadium-screen {
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
.stadium-board {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
min-height: calc(100dvh - 72px);
|
||||
}
|
||||
|
||||
.stadium-header,
|
||||
.stadium-pressure-panel {
|
||||
align-items: center;
|
||||
background: var(--panel);
|
||||
border: 3px solid #0c0d11;
|
||||
box-shadow: 4px 4px 0 #08090c;
|
||||
display: flex;
|
||||
grid-column: 1 / -1;
|
||||
justify-content: space-between;
|
||||
outline: 2px solid var(--edge);
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.stadium-header h2 {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.stadium-header > strong {
|
||||
color: var(--gold);
|
||||
font-family: 'Press Start 2P', monospace;
|
||||
font-size: 26px;
|
||||
}
|
||||
|
||||
.stadium-header p,
|
||||
.stadium-header small,
|
||||
.stadium-pressure-panel span {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.stadium-pressure-panel strong {
|
||||
color: var(--ink);
|
||||
font-family: 'Press Start 2P', monospace;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.stadium-side {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.stadium-shop-dialog {
|
||||
max-width: 1180px !important;
|
||||
padding: 16px !important;
|
||||
text-align: left !important;
|
||||
width: min(1180px, calc(100vw - 32px));
|
||||
}
|
||||
|
||||
.stadium-shop-summary {
|
||||
color: var(--muted);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
font-family: 'Press Start 2P', monospace;
|
||||
font-size: 12px;
|
||||
gap: 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.stadium-shop-layout {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
grid-template-columns: 240px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.stadium-shop-tabs {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.stadium-shop-tabs button,
|
||||
.stadium-shop-grid button {
|
||||
background: #303545;
|
||||
border: 2px solid #0b0c0f;
|
||||
color: var(--ink);
|
||||
cursor: pointer;
|
||||
font-family: 'Press Start 2P', monospace;
|
||||
outline: 2px solid #4d4c58;
|
||||
}
|
||||
|
||||
.stadium-shop-tabs button {
|
||||
font-size: 9px;
|
||||
margin: 0;
|
||||
padding: 10px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.stadium-shop-tabs button.active {
|
||||
background: #303427;
|
||||
outline-color: var(--gold);
|
||||
}
|
||||
|
||||
.stadium-shop-layout h3 {
|
||||
font-size: 15px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.stadium-shop-grid {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.stadium-shop-grid button {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin: 0;
|
||||
min-height: 128px;
|
||||
padding: 14px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.stadium-shop-grid button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.58;
|
||||
}
|
||||
|
||||
.stadium-shop-grid strong {
|
||||
color: #fff0b8;
|
||||
font-size: 11px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.stadium-shop-grid small,
|
||||
.stadium-shop-layout p {
|
||||
color: #f0e8d2;
|
||||
font-family: 'VT323', monospace;
|
||||
font-size: 22px;
|
||||
line-height: 1.05;
|
||||
}
|
||||
|
||||
.dual-opponent-progress.stadium {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.result-screen button,
|
||||
.pause-screen button {
|
||||
background: var(--gold);
|
||||
@@ -6551,11 +7073,24 @@ h2 {
|
||||
}
|
||||
|
||||
.pvp-board,
|
||||
.stadium-board,
|
||||
.stadium-shop-layout,
|
||||
.pvp-choice-columns,
|
||||
.pvp-choice-columns .upgrade-choice-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.stadium-header,
|
||||
.stadium-pressure-panel,
|
||||
.stadium-shop-summary {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.stadium-shop-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.active-target-card,
|
||||
.mana-wrap {
|
||||
width: 100%;
|
||||
|
||||
+244
-125
@@ -1,12 +1,6 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { lazy, Suspense, useEffect, useMemo, useState } from 'react'
|
||||
import './App.css'
|
||||
import { CombatScreen } from './components/CombatScreen'
|
||||
import { AuthScreen } from './components/AuthScreen'
|
||||
import { CustomizeScreen } from './components/CustomizeScreen'
|
||||
import { EquipmentScreen } from './components/EquipmentScreen'
|
||||
import { PvPRoguelikeScreen } from './components/PvpRoguelikeScreen'
|
||||
import { TalentScreen } from './components/TalentScreen'
|
||||
import { SettingsScreen } from './components/SettingsScreen'
|
||||
import {
|
||||
loadCpuPvpLeaderboard,
|
||||
type CpuPvpLeaderboardEntry,
|
||||
@@ -27,6 +21,14 @@ import {
|
||||
} from './gameRepository'
|
||||
import { focusFirstControl } from './input.tsx'
|
||||
|
||||
const CombatScreen = lazy(() => import('./components/CombatScreen').then((module) => ({ default: module.CombatScreen })))
|
||||
const CustomizeScreen = lazy(() => import('./components/CustomizeScreen').then((module) => ({ default: module.CustomizeScreen })))
|
||||
const EquipmentScreen = lazy(() => import('./components/EquipmentScreen').then((module) => ({ default: module.EquipmentScreen })))
|
||||
const PvPRoguelikeScreen = lazy(() => import('./components/PvpRoguelikeScreen').then((module) => ({ default: module.PvPRoguelikeScreen })))
|
||||
const PvpStadiumScreen = lazy(() => import('./components/PvpStadiumScreen').then((module) => ({ default: module.PvpStadiumScreen })))
|
||||
const TalentScreen = lazy(() => import('./components/TalentScreen').then((module) => ({ default: module.TalentScreen })))
|
||||
const SettingsScreen = lazy(() => import('./components/SettingsScreen').then((module) => ({ default: module.SettingsScreen })))
|
||||
|
||||
type Screen =
|
||||
| 'menu'
|
||||
| 'dungeons'
|
||||
@@ -66,6 +68,17 @@ function activityInitials(name: string) {
|
||||
.join('')
|
||||
}
|
||||
|
||||
function ScreenLoading() {
|
||||
return (
|
||||
<main className="game-shell">
|
||||
<section className="message-panel">
|
||||
<p className="eyebrow">Opening Chronicle</p>
|
||||
<h1>Loading...</h1>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
type RoguelikeUpgradeTiming = 'boss' | 'encounter'
|
||||
type RoguelikeVariant = 'pve' | 'pvp'
|
||||
type RoguelikeAbilityLabelMode = 'ability' | 'slot'
|
||||
@@ -147,9 +160,138 @@ function App() {
|
||||
const [cpuLeaderboard, setCpuLeaderboard] = useState<CpuPvpLeaderboardEntry[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
setCpuLeaderboard(loadCpuPvpLeaderboard(pvpContentType))
|
||||
const frame = window.requestAnimationFrame(() => {
|
||||
setCpuLeaderboard(loadCpuPvpLeaderboard(pvpContentType))
|
||||
})
|
||||
return () => window.cancelAnimationFrame(frame)
|
||||
}, [pvpContentType, screen, roguelikeVariant])
|
||||
|
||||
const profileDungeons = profile?.dungeons
|
||||
const dungeonOptions = useMemo(
|
||||
() => profileDungeons?.filter((candidate) => candidate.contentType === 'dungeon') ?? [],
|
||||
[profileDungeons],
|
||||
)
|
||||
const raidOptions = useMemo(
|
||||
() => profileDungeons?.filter((candidate) => candidate.contentType === 'raid') ?? [],
|
||||
[profileDungeons],
|
||||
)
|
||||
const selectedDungeonOption = useMemo(
|
||||
() => dungeonOptions.find((candidate) => candidate.id === selectedDungeonId) ?? dungeonOptions[0],
|
||||
[dungeonOptions, selectedDungeonId],
|
||||
)
|
||||
const selectedRaidOption = useMemo(
|
||||
() => raidOptions.find((candidate) => candidate.id === selectedRaidId) ?? raidOptions[0],
|
||||
[raidOptions, selectedRaidId],
|
||||
)
|
||||
const activityOptions = useMemo(
|
||||
() => screen === 'raids' ? raidOptions : dungeonOptions,
|
||||
[dungeonOptions, raidOptions, screen],
|
||||
)
|
||||
const combatDungeonOption = useMemo(
|
||||
() => {
|
||||
if (!profileDungeons) return undefined
|
||||
return combatContentId < 0
|
||||
? profileDungeons.find((candidate) => candidate.contentType === roguelikeKind) ?? profileDungeons[0]
|
||||
: profileDungeons.find((candidate) => candidate.id === combatContentId) ?? profileDungeons[0]
|
||||
},
|
||||
[combatContentId, profileDungeons, roguelikeKind],
|
||||
)
|
||||
const combatDifficultyOption = useMemo(
|
||||
() => combatDungeonOption?.difficulties.find(
|
||||
(candidate) => candidate.id === selectedDifficultyId,
|
||||
) ?? combatDungeonOption?.difficulties[0],
|
||||
[combatDungeonOption, selectedDifficultyId],
|
||||
)
|
||||
const roguelikePool = useMemo(
|
||||
() => profileDungeons
|
||||
?.filter((candidate) => candidate.contentType === roguelikeKind)
|
||||
.flatMap((candidate) => candidate.encounters) ?? [],
|
||||
[profileDungeons, roguelikeKind],
|
||||
)
|
||||
const pvpPool = useMemo(
|
||||
() => profileDungeons
|
||||
?.filter((candidate) => candidate.contentType === pvpContentType)
|
||||
.flatMap((candidate) => candidate.encounters) ?? [],
|
||||
[profileDungeons, pvpContentType],
|
||||
)
|
||||
const tierOptions = useMemo(
|
||||
() => activityOptions
|
||||
.flatMap((option) => option.difficulties)
|
||||
.filter((difficulty, index, all) => (
|
||||
all.findIndex((candidate) => candidate.droppedItemLevel === difficulty.droppedItemLevel) === index
|
||||
))
|
||||
.sort((a, b) => a.droppedItemLevel - b.droppedItemLevel),
|
||||
[activityOptions],
|
||||
)
|
||||
const savedDifficulty = useMemo(
|
||||
() => profileDungeons
|
||||
?.flatMap((option) => option.difficulties)
|
||||
.find((candidate) => candidate.id === selectedDifficultyId),
|
||||
[profileDungeons, selectedDifficultyId],
|
||||
)
|
||||
const selectedTier = useMemo(
|
||||
() => {
|
||||
const characterLevel = profile?.character.level ?? 0
|
||||
const savedTier = tierOptions.find((candidate) => (
|
||||
candidate.droppedItemLevel === savedDifficulty?.droppedItemLevel
|
||||
&& characterLevel >= candidate.unlockLevel
|
||||
))
|
||||
if (savedTier) return savedTier
|
||||
for (let index = tierOptions.length - 1; index >= 0; index -= 1) {
|
||||
if (characterLevel >= tierOptions[index].unlockLevel) return tierOptions[index]
|
||||
}
|
||||
return tierOptions[0]
|
||||
},
|
||||
[profile?.character.level, savedDifficulty?.droppedItemLevel, tierOptions],
|
||||
)
|
||||
const selectedTierItemLevel = selectedTier?.droppedItemLevel ?? 0
|
||||
const activityPageCount = Math.max(1, Math.ceil(activityOptions.length / ACTIVITY_PAGE_SIZE))
|
||||
const currentActivityPage = Math.min(activityPage, activityPageCount - 1)
|
||||
const pagedActivityOptions = useMemo(
|
||||
() => activityOptions.slice(
|
||||
currentActivityPage * ACTIVITY_PAGE_SIZE,
|
||||
currentActivityPage * ACTIVITY_PAGE_SIZE + ACTIVITY_PAGE_SIZE,
|
||||
),
|
||||
[activityOptions, currentActivityPage],
|
||||
)
|
||||
const selectedActivityOption = useMemo(
|
||||
() => {
|
||||
const fallback = screen === 'raids' && selectedRaidOption ? selectedRaidOption : selectedDungeonOption
|
||||
const selectedActivityId = screen === 'raids' && selectedRaidOption ? selectedRaidOption.id : selectedDungeonOption?.id
|
||||
return activityOptions.find((candidate) => candidate.id === selectedActivityId)
|
||||
?? activityOptions[0]
|
||||
?? fallback
|
||||
},
|
||||
[activityOptions, screen, selectedDungeonOption, selectedRaidOption],
|
||||
)
|
||||
const selectedDifficultyOption = useMemo(
|
||||
() => selectedActivityOption?.difficulties.find(
|
||||
(candidate) => candidate.droppedItemLevel === selectedTierItemLevel,
|
||||
) ?? selectedActivityOption?.difficulties[0],
|
||||
[selectedActivityOption, selectedTierItemLevel],
|
||||
)
|
||||
const lootPreviewEncounters = useMemo(
|
||||
() => [...(selectedActivityOption?.encounters ?? [])]
|
||||
.filter((encounter) => encounter.isBoss)
|
||||
.sort((a, b) => lootSort === 'boss'
|
||||
? a.enemyName.localeCompare(b.enemyName) || a.sequence - b.sequence
|
||||
: a.sequence - b.sequence),
|
||||
[lootSort, selectedActivityOption?.encounters],
|
||||
)
|
||||
const lootPreviewByEncounterId = useMemo(
|
||||
() => new Map(lootPreviewEncounters.map((encounter) => [
|
||||
encounter.id,
|
||||
encounter.lootTables.filter((entry) => entry.difficultyId === selectedDifficultyOption?.id),
|
||||
])),
|
||||
[lootPreviewEncounters, selectedDifficultyOption?.id],
|
||||
)
|
||||
const leaderboardEntries = useMemo(
|
||||
() => selectedActivityOption?.leaderboards[leaderboardCategory].filter(
|
||||
(entry) => entry.difficultyId === selectedDifficultyOption?.id,
|
||||
) ?? [],
|
||||
[leaderboardCategory, selectedActivityOption?.leaderboards, selectedDifficultyOption?.id],
|
||||
)
|
||||
|
||||
function acceptSession(session: AuthSession) {
|
||||
setAccount(session.account)
|
||||
setProfile(session.profile)
|
||||
@@ -223,52 +365,61 @@ function App() {
|
||||
}
|
||||
|
||||
if (screen === 'combat') {
|
||||
const dungeon = combatContentId < 0
|
||||
? profile.dungeons.find((candidate) => candidate.contentType === roguelikeKind) ?? profile.dungeons[0]
|
||||
: profile.dungeons.find((candidate) => candidate.id === combatContentId) ?? profile.dungeons[0]
|
||||
const difficulty = dungeon.difficulties.find(
|
||||
(candidate) => candidate.id === selectedDifficultyId,
|
||||
) ?? dungeon.difficulties[0]
|
||||
const roguelikePool = profile.dungeons
|
||||
.filter((candidate) => candidate.contentType === roguelikeKind)
|
||||
.flatMap((candidate) => candidate.encounters)
|
||||
const dungeon = combatDungeonOption ?? profile.dungeons[0]
|
||||
const difficulty = combatDifficultyOption ?? dungeon.difficulties[0]
|
||||
return (
|
||||
<CombatScreen
|
||||
difficulty={difficulty}
|
||||
dungeon={dungeon}
|
||||
hardMode={false}
|
||||
marathonMode={selectedMarathonMode && combatContentId > 0}
|
||||
profile={profile}
|
||||
roguelikeMode={combatContentId < 0 ? roguelikeKind : undefined}
|
||||
roguelikeUpgradeTiming={combatContentId < 0 ? roguelikeUpgradeTiming : undefined}
|
||||
roguelikeAbilityLabelMode={combatContentId < 0 ? roguelikeAbilityLabelMode : undefined}
|
||||
roguelikeEncounterPool={combatContentId < 0 ? roguelikePool : undefined}
|
||||
startPart={1}
|
||||
onExit={() => {
|
||||
setScreen(combatContentId < 0 ? 'roguelike' : dungeon.contentType === 'raid' ? 'raids' : 'dungeons')
|
||||
}}
|
||||
onProfileUpdated={setProfile}
|
||||
/>
|
||||
<Suspense fallback={<ScreenLoading />}>
|
||||
<CombatScreen
|
||||
difficulty={difficulty}
|
||||
dungeon={dungeon}
|
||||
hardMode={false}
|
||||
marathonMode={selectedMarathonMode && combatContentId > 0}
|
||||
profile={profile}
|
||||
roguelikeMode={combatContentId < 0 ? roguelikeKind : undefined}
|
||||
roguelikeUpgradeTiming={combatContentId < 0 ? roguelikeUpgradeTiming : undefined}
|
||||
roguelikeAbilityLabelMode={combatContentId < 0 ? roguelikeAbilityLabelMode : undefined}
|
||||
roguelikeEncounterPool={combatContentId < 0 ? roguelikePool : undefined}
|
||||
startPart={1}
|
||||
onExit={() => {
|
||||
setScreen(combatContentId < 0 ? 'roguelike' : dungeon.contentType === 'raid' ? 'raids' : 'dungeons')
|
||||
}}
|
||||
onProfileUpdated={setProfile}
|
||||
/>
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
if (screen === 'pvp') {
|
||||
const pvpPool = profile.dungeons
|
||||
.filter((candidate) => candidate.contentType === pvpContentType)
|
||||
.flatMap((candidate) => candidate.encounters)
|
||||
if (pvpContentType === 'stadium') {
|
||||
return (
|
||||
<Suspense fallback={<ScreenLoading />}>
|
||||
<PvpStadiumScreen
|
||||
gameMode={gameMode}
|
||||
onExit={() => {
|
||||
setRoguelikeVariant('pvp')
|
||||
setScreen('roguelike')
|
||||
}}
|
||||
onProfileUpdated={setProfile}
|
||||
profile={profile}
|
||||
/>
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<PvPRoguelikeScreen
|
||||
contentType={pvpContentType}
|
||||
encounterPool={pvpPool}
|
||||
gameMode={gameMode}
|
||||
onExit={() => {
|
||||
setCpuLeaderboard(loadCpuPvpLeaderboard(pvpContentType))
|
||||
setRoguelikeVariant('pvp')
|
||||
setScreen('roguelike')
|
||||
}}
|
||||
onProfileUpdated={setProfile}
|
||||
profile={profile}
|
||||
/>
|
||||
<Suspense fallback={<ScreenLoading />}>
|
||||
<PvPRoguelikeScreen
|
||||
contentType={pvpContentType}
|
||||
encounterPool={pvpPool}
|
||||
gameMode={gameMode}
|
||||
onExit={() => {
|
||||
setCpuLeaderboard(loadCpuPvpLeaderboard(pvpContentType))
|
||||
setRoguelikeVariant('pvp')
|
||||
setScreen('roguelike')
|
||||
}}
|
||||
onProfileUpdated={setProfile}
|
||||
profile={profile}
|
||||
/>
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -279,13 +430,7 @@ function App() {
|
||||
const experiencePercent = profile.character.level >= profile.maxLevel
|
||||
? 100
|
||||
: Math.min(100, (experienceIntoLevel / experienceForLevel) * 100)
|
||||
const dungeonOptions = profile.dungeons.filter((candidate) => candidate.contentType === 'dungeon')
|
||||
const raidOptions = profile.dungeons.filter((candidate) => candidate.contentType === 'raid')
|
||||
const dungeon = dungeonOptions.find((candidate) => candidate.id === selectedDungeonId)
|
||||
?? dungeonOptions[0]!
|
||||
const raid = raidOptions.find((candidate) => candidate.id === selectedRaidId)
|
||||
?? raidOptions[0]
|
||||
const activityOptions = screen === 'raids' ? raidOptions : dungeonOptions
|
||||
const dungeon = selectedDungeonOption ?? profile.dungeons[0]!
|
||||
const startPveRoguelike = () => {
|
||||
const baseDungeon = dungeonOptions[0]
|
||||
const baseRaid = raidOptions[0]
|
||||
@@ -299,47 +444,15 @@ function App() {
|
||||
setSelectedMarathonMode(false)
|
||||
setScreen('combat')
|
||||
}
|
||||
const tierOptions = activityOptions
|
||||
.flatMap((option) => option.difficulties)
|
||||
.filter((difficulty, index, all) => (
|
||||
all.findIndex((candidate) => candidate.droppedItemLevel === difficulty.droppedItemLevel) === index
|
||||
))
|
||||
.sort((a, b) => a.droppedItemLevel - b.droppedItemLevel)
|
||||
const savedDifficulty = profile.dungeons
|
||||
.flatMap((option) => option.difficulties)
|
||||
.find((candidate) => candidate.id === selectedDifficultyId)
|
||||
const selectedTier = tierOptions.find((candidate) => (
|
||||
candidate.droppedItemLevel === savedDifficulty?.droppedItemLevel
|
||||
&& profile.character.level >= candidate.unlockLevel
|
||||
))
|
||||
?? tierOptions.slice().reverse().find((candidate) => profile.character.level >= candidate.unlockLevel)
|
||||
?? tierOptions[0]
|
||||
const selectedTierItemLevel = selectedTier?.droppedItemLevel ?? 0
|
||||
const activityPageCount = Math.max(1, Math.ceil(activityOptions.length / ACTIVITY_PAGE_SIZE))
|
||||
const currentActivityPage = Math.min(activityPage, activityPageCount - 1)
|
||||
const pagedActivityOptions = activityOptions.slice(
|
||||
currentActivityPage * ACTIVITY_PAGE_SIZE,
|
||||
currentActivityPage * ACTIVITY_PAGE_SIZE + ACTIVITY_PAGE_SIZE,
|
||||
)
|
||||
const activityPageStart = activityOptions.length === 0
|
||||
? 0
|
||||
: currentActivityPage * ACTIVITY_PAGE_SIZE + 1
|
||||
const activityPageEnd = Math.min(activityOptions.length, (currentActivityPage + 1) * ACTIVITY_PAGE_SIZE)
|
||||
const selectedActivityId = screen === 'raids' && raid ? raid.id : dungeon.id
|
||||
const activity = activityOptions.find((candidate) => candidate.id === selectedActivityId)
|
||||
?? activityOptions[0]
|
||||
?? (screen === 'raids' && raid ? raid : dungeon)
|
||||
const selectedDifficulty = activity.difficulties.find(
|
||||
(candidate) => candidate.droppedItemLevel === selectedTierItemLevel,
|
||||
) ?? activity.difficulties[0]
|
||||
const activity = selectedActivityOption ?? dungeon
|
||||
const selectedDifficulty = selectedDifficultyOption ?? activity.difficulties[0]
|
||||
const difficultyLocked = profile.character.level < selectedDifficulty.unlockLevel
|
||||
const cloudSync = getCloudSyncStatus()
|
||||
const canShowCloudSync = account.id !== -1 && cloudSync.available
|
||||
const lootPreviewEncounters = [...activity.encounters]
|
||||
.filter((encounter) => encounter.isBoss)
|
||||
.sort((a, b) => lootSort === 'boss'
|
||||
? a.enemyName.localeCompare(b.enemyName) || a.sequence - b.sequence
|
||||
: a.sequence - b.sequence)
|
||||
|
||||
return (
|
||||
<main className={`game-shell ${screen === 'dungeons' || screen === 'raids' ? 'dungeon-shell' : ''} ${screen === 'customize' ? 'workshop-shell' : ''}`}>
|
||||
@@ -547,6 +660,13 @@ function App() {
|
||||
>
|
||||
Raid
|
||||
</button>
|
||||
<button
|
||||
className={`text-button ${pvpContentType === 'stadium' ? 'active' : ''}`}
|
||||
onClick={() => setPvpContentType('stadium')}
|
||||
type="button"
|
||||
>
|
||||
Stadium
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="menu-card pvp-queue-panel">
|
||||
@@ -554,9 +674,11 @@ function App() {
|
||||
<div>
|
||||
<strong>{gameMode === 'offline' ? 'Offline CPU Match' : 'Queue Then CPU Fallback'}</strong>
|
||||
<small>
|
||||
{gameMode === 'offline'
|
||||
? 'Offline mode always places you against a random CPU 1-5.'
|
||||
: 'Online mode searches briefly. If nobody is queued, a random CPU 1-5 takes the slot.'}
|
||||
{pvpContentType === 'stadium'
|
||||
? 'Best-of-5 survival with dampening, equalized gear, and after-round buff buying.'
|
||||
: gameMode === 'offline'
|
||||
? 'Offline mode always places you against a random CPU 1-5.'
|
||||
: 'Online mode searches briefly. If nobody is queued, a random CPU 1-5 takes the slot.'}
|
||||
</small>
|
||||
</div>
|
||||
<button
|
||||
@@ -572,7 +694,7 @@ function App() {
|
||||
<div className="equipment-heading toggle-heading">
|
||||
<div>
|
||||
<p className="eyebrow">CPU Leaderboard</p>
|
||||
<h2>{pvpContentType === 'raid' ? 'Raid Clash' : 'Dungeon Clash'}</h2>
|
||||
<h2>{pvpContentType === 'stadium' ? 'Stadium' : pvpContentType === 'raid' ? 'Raid Clash' : 'Dungeon Clash'}</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="leaderboard-table">
|
||||
@@ -828,9 +950,7 @@ function App() {
|
||||
</p>
|
||||
<div className="loot-preview-grid">
|
||||
{lootPreviewEncounters.map((encounter) => {
|
||||
const loot = encounter.lootTables.filter(
|
||||
(entry) => entry.difficultyId === selectedDifficulty.id,
|
||||
)
|
||||
const loot = lootPreviewByEncounterId.get(encounter.id) ?? []
|
||||
return (
|
||||
<article key={encounter.id}>
|
||||
<div className="loot-encounter-title">
|
||||
@@ -914,8 +1034,7 @@ function App() {
|
||||
<span>Resource</span>
|
||||
<span>Time</span>
|
||||
</div>
|
||||
{activity.leaderboards[leaderboardCategory]
|
||||
.filter((entry) => entry.difficultyId === selectedDifficulty.id)
|
||||
{leaderboardEntries
|
||||
.map((entry) => (
|
||||
<div className="leaderboard-row" key={`${entry.rank}-${entry.completedAt}`}>
|
||||
<strong>#{entry.rank}</strong>
|
||||
@@ -927,9 +1046,7 @@ function App() {
|
||||
<span>{entry.durationSeconds}s</span>
|
||||
</div>
|
||||
))}
|
||||
{activity.leaderboards[leaderboardCategory].filter(
|
||||
(entry) => entry.difficultyId === selectedDifficulty.id,
|
||||
).length === 0 && (
|
||||
{leaderboardEntries.length === 0 && (
|
||||
<div className="leaderboard-empty">
|
||||
{gameMode === 'offline'
|
||||
? 'Connect with an online character to compete in rankings.'
|
||||
@@ -948,33 +1065,35 @@ function App() {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{screen === 'customize' && (
|
||||
<CustomizeScreen
|
||||
profile={profile}
|
||||
onBack={() => setScreen('menu')}
|
||||
onSaved={setProfile}
|
||||
/>
|
||||
)}
|
||||
<Suspense fallback={<ScreenLoading />}>
|
||||
{screen === 'customize' && (
|
||||
<CustomizeScreen
|
||||
profile={profile}
|
||||
onBack={() => setScreen('menu')}
|
||||
onSaved={setProfile}
|
||||
/>
|
||||
)}
|
||||
|
||||
{screen === 'talents' && (
|
||||
<TalentScreen
|
||||
profile={profile}
|
||||
onBack={() => setScreen('menu')}
|
||||
onUpdated={setProfile}
|
||||
/>
|
||||
)}
|
||||
{screen === 'talents' && (
|
||||
<TalentScreen
|
||||
profile={profile}
|
||||
onBack={() => setScreen('menu')}
|
||||
onUpdated={setProfile}
|
||||
/>
|
||||
)}
|
||||
|
||||
{screen === 'equipment' && (
|
||||
<EquipmentScreen
|
||||
profile={profile}
|
||||
onBack={() => setScreen('menu')}
|
||||
onUpdated={setProfile}
|
||||
/>
|
||||
)}
|
||||
{screen === 'equipment' && (
|
||||
<EquipmentScreen
|
||||
profile={profile}
|
||||
onBack={() => setScreen('menu')}
|
||||
onUpdated={setProfile}
|
||||
/>
|
||||
)}
|
||||
|
||||
{screen === 'settings' && (
|
||||
<SettingsScreen onBack={() => setScreen('menu')} />
|
||||
)}
|
||||
{screen === 'settings' && (
|
||||
<SettingsScreen onBack={() => setScreen('menu')} />
|
||||
)}
|
||||
</Suspense>
|
||||
|
||||
</main>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
# Combat Modules
|
||||
|
||||
Shared combat code is split by responsibility:
|
||||
|
||||
- `spellEffects.ts` owns spell target plans and the shared spell-effect applier.
|
||||
- `combatEngine.ts` owns repeated per-member tick math: damage reduction, shields, HoTs, poison, debuff timers, and bounce-heal jumps.
|
||||
- `combatPresentation.ts` owns UI-facing caps and grouping for combat logs and floating combat text.
|
||||
- Screen files provide `SpellEffectProfile` objects for mode-specific numbers.
|
||||
- `spellCasting.ts` owns cast readiness and cooldown writes.
|
||||
- `pvpSpellCasting.ts` owns the shared PvP cast pipeline: readiness validation, live target validation, effect application, floating-heal emission, and side-state writes.
|
||||
- `combatTick.ts` owns small tick helpers such as resource regeneration and cooldown decay.
|
||||
- `cpuAi.ts` owns CPU healer timing, mistake rolls, spell choice, and turn execution.
|
||||
- `stackCounts.ts` owns stacked buff/upgrade count maps and summaries.
|
||||
- `spellModifiers.ts` owns stack-based spell modifiers such as cost, cooldown, extra targets, free casts, and power multipliers.
|
||||
- `combatStateTransitions.ts` owns shared combat state writes such as applying a completed cast to party, resource, cooldown, and free-cast fields.
|
||||
- `stadiumLifecycle.ts` owns pure Stadium round/shop decisions: round outcome resolution, shop point awards, and CPU purchase selection.
|
||||
- `pvpRoguelikeLifecycle.ts` owns pure PvP roguelike lifecycle decisions: live snapshot outcomes, combat tick outcomes, upgrade-complete checks, and next-stage progression.
|
||||
- `pvpRoguelikeUpgrades.ts` owns PvP roguelike upgrade application, revive cleanup, party recovery, resource restore, cooldown reset, and next enemy health setup.
|
||||
- `pvpRoguelikeMatchSetup.ts` owns PvP roguelike match-start side creation, first segment setup, live opponent naming, and shared reset defaults.
|
||||
- `stadiumMatchSetup.ts` owns Stadium starter side creation, local/live match setup, live opponent naming, and shared reset defaults.
|
||||
- `pveRoguelikeRunSetup.ts` owns PvE combat-state creation and roguelike run reset defaults.
|
||||
- `rewardSummaries.ts` owns reward summary initialization, unlocked ability merging, XP/level/talent accumulation, and PvP roguelike boss loot accumulation.
|
||||
- `dualScreenPayloads.ts` owns dual-screen combat payload construction and mode-specific status normalization.
|
||||
- `pvpLiveLifecycle.ts` owns live PvP polling, local progress publication, and rematch request state.
|
||||
|
||||
## SpellEffectProfile
|
||||
|
||||
Use a profile when spell behavior is the same shape but different by mode.
|
||||
|
||||
Examples:
|
||||
|
||||
- PvE stores Renew-style effects in `hotEffects`; PvP modes mostly use `hotTicks`.
|
||||
- Stadium applies dampening through profile power callbacks.
|
||||
- PvE cleanse intentionally does not emit floating heal text; PvP modes do.
|
||||
- Shield ratios such as Radiance `0.3`, Mend `0.5`, and Stadium group shield `0.5` belong in profiles.
|
||||
|
||||
When adding a spell rule, prefer adding a named profile field or callback instead of branching in screen components.
|
||||
|
||||
## StackCounts
|
||||
|
||||
Use `createStackCounts` once per party state, upgrade list, or cast snapshot when repeated math needs stack totals. Then pass the map to spell cost, cooldown, target, and effect helpers. This keeps stacked upgrade behavior consistent and avoids repeated `filter`/scan work in combat render and cast paths.
|
||||
|
||||
## Spell Modifiers
|
||||
|
||||
Use `spellModifiers.ts` for stacked upgrade math instead of open-coded exponent formulas in screen components. Screens still own mode-specific IDs, but shared helpers own the math:
|
||||
|
||||
- slot cost/cooldown reducers default to `0.75 ** stacks`.
|
||||
- opponent slot penalties default to `1.25 ** stacks`.
|
||||
- power boosts default to `1.25 ** stacks`.
|
||||
- free-cast checks use a named stack modifier and the mode's existing `freeCastReady` rules.
|
||||
|
||||
## State Transitions
|
||||
|
||||
Use `applyCastStateUpdate` after a spell effect profile returns the next party. Screens can still prepare mode-specific cooldown adjustments before the spell cooldown lands, but the final resource spend, cooldown write, party replacement, and free-cast progress should go through the transition helper.
|
||||
|
||||
## PvP Spell Casting
|
||||
|
||||
Use `applyPvpSpellCast` after a PvP screen builds its mode-specific target plan and `SpellEffectProfile`. Keep balance inputs such as roguelike effects, Stadium dampening, shop buffs, cooldown tweaks, and free-cast flags in the screen; let the helper own the repeated validate/apply/emit/write pipeline.
|
||||
|
||||
## CPU AI
|
||||
|
||||
Use `runCpuHealTurn` for PvP CPU turns. Screens still own mode-specific behavior tables and apply-spell adapters, while `cpuAi.ts` owns timing checks, mistake rolls, action selection, and invoking the selected spell.
|
||||
|
||||
## Stadium Lifecycle
|
||||
|
||||
Use `resolveStadiumRound` for win/loss/tie decisions instead of open-coding score checks in `PvpStadiumScreen`. Use `stadiumShopPointsForOutcome` and `chooseStadiumCpuPurchases` for shop setup so player and CPU point rules stay in one place.
|
||||
|
||||
## PvP Roguelike Lifecycle
|
||||
|
||||
Use `resolvePvpRoguelikeCombatOutcome` after each tick advances both sides. Use `resolvePvpRoguelikeLiveSnapshot` when a live match snapshot arrives. Use `nextPvpRoguelikeStage` after upgrade choices to decide whether to continue, advance stage, or complete the match. Components should execute the returned intent with React setters instead of duplicating the decision tree.
|
||||
|
||||
## PvP Roguelike Upgrades
|
||||
|
||||
Use `applyPvpRoguelikeUpgradeChoice` to apply selected buffs, incoming debuffs, and revive-buff debuff cleanup. Use `preparePvpRoguelikeNextEncounter` after progression chooses the next encounter so recovery, 25% resource restore, cooldown reset, and enemy health setup stay consistent between live and CPU matches.
|
||||
|
||||
## PvP Roguelike Match Setup
|
||||
|
||||
Use `createPvpRoguelikeMatchStart` for local/CPU starts and `createPvpRoguelikeLiveMatchStart` for live matches. These helpers build starter sides, set first encounter health, name live opponents, and provide reset defaults so rematch, checkpoint start, and queue fallback do not duplicate initial state construction.
|
||||
|
||||
## Stadium Match Setup
|
||||
|
||||
Use `createStadiumMatchStart` for queue/CPU starts, `createStadiumLiveMatchStart` for live matches, and `createStadiumStarterSide` for advancing to the next round. These helpers reset party state, carry purchased buffs and round wins where needed, and provide shared reset defaults for queue, rematch, and live match starts.
|
||||
|
||||
## PvE Run Setup
|
||||
|
||||
Use `createPveCombatState` for initial single-player combat state and `createPveRoguelikeRunStart` when resetting a run. This keeps party cloning, resource setup, enemy health, cooldown/free-cast reset, and UI reset defaults in one place.
|
||||
|
||||
## Reward Summaries
|
||||
|
||||
Use `createEmptyRewardSummary`, `createEmptyPvpRunSummary`, `mergeDungeonRewardSummary`, and `mergePvpRunRewardSummary` instead of open-coding reward accumulation in screens. This keeps XP, level, talent points, unlocked abilities, and boss loot merge behavior consistent.
|
||||
|
||||
Presentation components for these summaries live in `components/RewardPanels.tsx`: use `RewardXpSummary`, `BonusItemReward`, `LootRollList`, and `PvpRunLootList` instead of duplicating reward JSX in result screens.
|
||||
|
||||
## Dual-Screen Payloads
|
||||
|
||||
Use the builders in `dualScreenPayloads.ts` when publishing combat state to the secondary display. Keep render-derived arrays such as stripped floating text and opponent summaries memoized before calling the builder, then let the builder normalize mode status values like queueing, countdown, and Stadium shop into the bottom-screen status model.
|
||||
|
||||
## Live PvP Sync
|
||||
|
||||
Use `usePvpLiveMatchSync` for live match polling and rematch controls. Screens should provide only mode-specific progress payloads and opponent snapshot rules; the hook owns publish/poll intervals, rematch pending state, expiry, and error messages.
|
||||
|
||||
## Timers
|
||||
|
||||
Use `useRoundCountdown` for PvP round starts and `useDeadlineTimer` for mode deadlines such as PvP roguelike upgrade choice and Stadium shop. Keep countdown expiry behavior mode-specific, but avoid open-coded interval refs and `Date.now()` loops in screens.
|
||||
|
||||
## Floating Combat Text
|
||||
|
||||
Use `useFloatingCombatText` for single-sided combat screens and `useSidedFloatingCombatText` for PvP screens. The hooks own IDs, expiry cleanup, member grouping, side filtering, and dual-screen text stripping so screens only emit heal events.
|
||||
|
||||
## Party Targeting
|
||||
|
||||
Use `usePartyTargeting` for combat-screen navigation wrappers. Keep raw grid/direct selection rules in `combat/targeting.ts`; the hook owns relative, directional, and direct target callbacks around the current party ref, selected-id ref, grid columns, living-target flags, and target group.
|
||||
|
||||
## Render Allocation
|
||||
|
||||
Keep repeated combat-render derivations memoized near their source state: active input bindings, stack summaries, visible shop choices, enemy health segments, and stripped floating text payloads. Avoid calling summary helpers or `bindings[lastDevice]` repeatedly inside JSX loops.
|
||||
@@ -0,0 +1,170 @@
|
||||
import type { PartyMember } from '../game'
|
||||
import {
|
||||
clamp,
|
||||
healAmount,
|
||||
memberHotEffects,
|
||||
tickHotEffects,
|
||||
} from './rules'
|
||||
|
||||
export type MemberTickInput = {
|
||||
member: PartyMember
|
||||
party: PartyMember[]
|
||||
damage: number
|
||||
hotHealing: number
|
||||
hotTicks: 'effects' | 'ticks'
|
||||
healingMultiplier?: number
|
||||
damageReductionMultiplier?: number
|
||||
damageReductionRounding?: 'round' | 'ceil'
|
||||
shieldedDamageMultiplier?: number
|
||||
applyDebuff?: {
|
||||
label: string
|
||||
ticks: number
|
||||
}
|
||||
applyPoisonStacks?: boolean
|
||||
poisonDamage?: (stacks: number) => number
|
||||
applyMaxHealthPenaltyTicks?: number
|
||||
applyHealingReductionTicks?: number
|
||||
decrementDamageReduction?: boolean
|
||||
useBounceHeals?: boolean
|
||||
jumpTarget?: () => PartyMember | undefined
|
||||
}
|
||||
|
||||
export type MemberTickResult = {
|
||||
member: PartyMember
|
||||
floatingHeal: number
|
||||
jumpedBounceHeals: Array<{
|
||||
targetId: string
|
||||
heal: NonNullable<PartyMember['bounceHeals']>[number]
|
||||
}>
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies one combat tick to a party member. Screen-level engines still decide
|
||||
* who is targeted and when fights end; this helper owns repeated health math:
|
||||
* damage reduction, shields, HoT healing, debuff timers, poison, and bounce heals.
|
||||
*/
|
||||
export function advanceMemberTick({
|
||||
member,
|
||||
party,
|
||||
damage,
|
||||
hotHealing,
|
||||
hotTicks,
|
||||
healingMultiplier = 1,
|
||||
damageReductionMultiplier = 0.5,
|
||||
damageReductionRounding = 'round',
|
||||
shieldedDamageMultiplier,
|
||||
applyDebuff,
|
||||
applyPoisonStacks = false,
|
||||
poisonDamage = () => 0,
|
||||
applyMaxHealthPenaltyTicks,
|
||||
applyHealingReductionTicks,
|
||||
decrementDamageReduction = false,
|
||||
useBounceHeals = false,
|
||||
jumpTarget,
|
||||
}: MemberTickInput): MemberTickResult {
|
||||
if (member.health <= 0) {
|
||||
return { member, floatingHeal: 0, jumpedBounceHeals: [] }
|
||||
}
|
||||
|
||||
let nextDamage = damage
|
||||
const nextPoisonStacks = applyPoisonStacks
|
||||
? Math.max(1, (member.poisonStacks ?? 0) + 1)
|
||||
: member.poisonStacks ?? 0
|
||||
if (nextPoisonStacks > 0) nextDamage += poisonDamage(nextPoisonStacks)
|
||||
|
||||
if ((member.damageReductionTicks ?? 0) > 0) {
|
||||
const reducedDamage = nextDamage * damageReductionMultiplier
|
||||
nextDamage = damageReductionRounding === 'ceil'
|
||||
? Math.ceil(reducedDamage)
|
||||
: Math.round(reducedDamage)
|
||||
}
|
||||
if (member.shield > 0 && shieldedDamageMultiplier !== undefined) {
|
||||
nextDamage = Math.round(nextDamage * shieldedDamageMultiplier)
|
||||
}
|
||||
|
||||
const absorbed = Math.min(member.shield, nextDamage)
|
||||
const hotEffects = hotTicks === 'effects' ? memberHotEffects(member) : []
|
||||
let healing = hotTicks === 'effects'
|
||||
? hotEffects.reduce((total, effect) => total + healAmount(member, effect.power, healingMultiplier), 0)
|
||||
: member.hotTicks > 0 ? healAmount(member, hotHealing, healingMultiplier) : 0
|
||||
|
||||
let nextBounceHeals = [...(member.bounceHeals ?? [])]
|
||||
const jumpedBounceHeals: MemberTickResult['jumpedBounceHeals'] = []
|
||||
if (useBounceHeals && nextDamage > 0 && nextBounceHeals.length > 0) {
|
||||
nextBounceHeals = nextBounceHeals.flatMap((effect) => {
|
||||
healing += healAmount(member, effect.power, healingMultiplier)
|
||||
const nextCharges = effect.charges - 1
|
||||
if (nextCharges <= 0) return []
|
||||
const target = jumpTarget?.() ?? party.find((candidate) => candidate.health > 0 && candidate.id !== member.id) ?? member
|
||||
jumpedBounceHeals.push({
|
||||
targetId: target.id,
|
||||
heal: { ...effect, charges: nextCharges },
|
||||
})
|
||||
return []
|
||||
})
|
||||
}
|
||||
|
||||
const nextMaxHealthPenaltyTicks = applyMaxHealthPenaltyTicks !== undefined
|
||||
? applyMaxHealthPenaltyTicks
|
||||
: Math.max(0, (member.maxHealthPenaltyTicks ?? 0) - 1)
|
||||
const nextHealingReductionTicks = applyHealingReductionTicks !== undefined
|
||||
? applyHealingReductionTicks
|
||||
: Math.max(0, (member.healingReductionTicks ?? 0) - 1)
|
||||
const nextDebuffTicks = applyDebuff
|
||||
? applyDebuff.ticks
|
||||
: Math.max(0, (member.debuffTicks ?? 0) - 1)
|
||||
const nextEffectiveMaxHealth = Math.max(1, Math.round(
|
||||
member.maxHealth * (nextMaxHealthPenaltyTicks > 0 ? 0.75 : 1),
|
||||
))
|
||||
|
||||
return {
|
||||
member: {
|
||||
...member,
|
||||
health: clamp(
|
||||
clamp(member.health + healing, 0, nextEffectiveMaxHealth) - nextDamage + absorbed,
|
||||
0,
|
||||
nextEffectiveMaxHealth,
|
||||
),
|
||||
shield: Math.max(0, member.shield - nextDamage),
|
||||
hotTicks: hotTicks === 'effects' ? 0 : Math.max(0, member.hotTicks - 1),
|
||||
hotEffects: hotTicks === 'effects' ? tickHotEffects(hotEffects) : member.hotEffects,
|
||||
bounceHeals: useBounceHeals ? nextBounceHeals : member.bounceHeals,
|
||||
damageReductionTicks: decrementDamageReduction
|
||||
? Math.max(0, (member.damageReductionTicks ?? 0) - 1)
|
||||
: member.damageReductionTicks,
|
||||
debuff: nextDebuffTicks > 0
|
||||
? applyDebuff?.label ?? member.debuff
|
||||
: undefined,
|
||||
debuffTicks: nextDebuffTicks > 0 ? nextDebuffTicks : undefined,
|
||||
poisonStacks: nextPoisonStacks,
|
||||
maxHealthPenaltyTicks: nextMaxHealthPenaltyTicks,
|
||||
healingReductionTicks: nextHealingReductionTicks,
|
||||
},
|
||||
floatingHeal: healing,
|
||||
jumpedBounceHeals,
|
||||
}
|
||||
}
|
||||
|
||||
export function attachJumpedBounceHeals(
|
||||
party: PartyMember[],
|
||||
jumpedBounceHeals: MemberTickResult['jumpedBounceHeals'],
|
||||
) {
|
||||
if (jumpedBounceHeals.length === 0) return party
|
||||
const jumpedByTarget = new Map<string, NonNullable<PartyMember['bounceHeals']>>()
|
||||
for (const jump of jumpedBounceHeals) {
|
||||
const current = jumpedByTarget.get(jump.targetId)
|
||||
if (current) current.push(jump.heal)
|
||||
else jumpedByTarget.set(jump.targetId, [jump.heal])
|
||||
}
|
||||
return party.map((member) => {
|
||||
const jumped = jumpedByTarget.get(member.id)
|
||||
if (!jumped || jumped.length === 0) return member
|
||||
return {
|
||||
...member,
|
||||
bounceHeals: [
|
||||
...(member.bounceHeals ?? []),
|
||||
...jumped,
|
||||
],
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { CombatLogEntry } from '../game'
|
||||
|
||||
export type BasicFloatingCombatText = {
|
||||
id: number
|
||||
memberId: string
|
||||
value: number
|
||||
}
|
||||
|
||||
export const DEFAULT_COMBAT_LOG_LIMIT = 60
|
||||
export const STADIUM_COMBAT_LOG_LIMIT = 70
|
||||
export const DEFAULT_FLOATING_TEXT_LIMIT = 48
|
||||
|
||||
export function appendCombatLog(
|
||||
current: CombatLogEntry[],
|
||||
entry: CombatLogEntry,
|
||||
limit = DEFAULT_COMBAT_LOG_LIMIT,
|
||||
) {
|
||||
return [entry, ...current].slice(0, limit)
|
||||
}
|
||||
|
||||
export function appendFloatingText<T extends BasicFloatingCombatText>(
|
||||
current: T[],
|
||||
entry: T,
|
||||
limit = DEFAULT_FLOATING_TEXT_LIMIT,
|
||||
) {
|
||||
return [...current, entry].slice(-limit)
|
||||
}
|
||||
|
||||
export function groupFloatingTextsByMember<T extends BasicFloatingCombatText>(texts: T[]) {
|
||||
const groups = new Map<string, T[]>()
|
||||
texts.forEach((entry) => {
|
||||
const current = groups.get(entry.memberId)
|
||||
if (current) current.push(entry)
|
||||
else groups.set(entry.memberId, [entry])
|
||||
})
|
||||
return groups
|
||||
}
|
||||
|
||||
export function stripFloatingTextSide<T extends BasicFloatingCombatText>(texts: T[]): BasicFloatingCombatText[] {
|
||||
return texts.map(({ id, memberId, value }) => ({ id, memberId, value }))
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { PartyMember, Spell } from '../game'
|
||||
import { putSpellOnCooldown } from './spellCasting'
|
||||
import { advanceFreeCastProgress } from './spellEffects'
|
||||
|
||||
export type CastStateFields<TParty extends readonly PartyMember[] = PartyMember[]> = {
|
||||
party: TParty
|
||||
resource: number
|
||||
cooldowns: Record<string, number>
|
||||
castsTowardFree: number
|
||||
freeCastReady: boolean
|
||||
}
|
||||
|
||||
export function applyCastStateUpdate<
|
||||
TParty extends readonly PartyMember[],
|
||||
TState extends CastStateFields<TParty>,
|
||||
>({
|
||||
current,
|
||||
party,
|
||||
spell,
|
||||
resourceCost,
|
||||
cooldownMultiplier = 1,
|
||||
cooldowns = current.cooldowns,
|
||||
freeCast = { enabled: false, wasReady: false },
|
||||
}: {
|
||||
current: TState
|
||||
party: TParty
|
||||
spell: Spell
|
||||
resourceCost: number
|
||||
cooldownMultiplier?: number
|
||||
cooldowns?: Record<string, number>
|
||||
freeCast?: {
|
||||
enabled: boolean
|
||||
wasReady: boolean
|
||||
}
|
||||
}) {
|
||||
const freeCastProgress = advanceFreeCastProgress({
|
||||
enabled: freeCast.enabled,
|
||||
wasReady: freeCast.wasReady,
|
||||
castsTowardFree: current.castsTowardFree,
|
||||
})
|
||||
|
||||
return {
|
||||
...current,
|
||||
party,
|
||||
resource: current.resource - resourceCost,
|
||||
cooldowns: putSpellOnCooldown(cooldowns, spell, cooldownMultiplier),
|
||||
castsTowardFree: freeCastProgress.castsTowardFree,
|
||||
freeCastReady: freeCastProgress.freeCastReady,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { clamp } from './rules'
|
||||
|
||||
export function tickSeconds(tickMs: number) {
|
||||
return tickMs / 1000
|
||||
}
|
||||
|
||||
export function advanceCooldowns(cooldowns: Record<string, number>, elapsedSeconds: number) {
|
||||
const nextCooldowns: Record<string, number> = {}
|
||||
for (const id in cooldowns) {
|
||||
nextCooldowns[id] = Math.max(0, cooldowns[id] - elapsedSeconds)
|
||||
}
|
||||
return nextCooldowns
|
||||
}
|
||||
|
||||
export function regenerateResource(resource: number, amount: number, maxResource: number) {
|
||||
return clamp(resource + amount, 0, maxResource)
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import type { PartyMember, Spell } from '../game'
|
||||
import { effectiveMaxHealth } from './rules'
|
||||
|
||||
export type CpuHealBehavior = {
|
||||
directHealThreshold: number
|
||||
groupHealThreshold: number
|
||||
hotThreshold: number
|
||||
shieldThreshold: number
|
||||
}
|
||||
|
||||
export type CpuTurnBehavior = CpuHealBehavior & {
|
||||
actionEveryTicks: number
|
||||
mistakeChance: number
|
||||
}
|
||||
|
||||
export function shouldCpuAct({
|
||||
elapsedTicks,
|
||||
behavior,
|
||||
}: {
|
||||
elapsedTicks: number
|
||||
behavior: CpuTurnBehavior
|
||||
}) {
|
||||
return elapsedTicks % behavior.actionEveryTicks === 0 && Math.random() >= behavior.mistakeChance
|
||||
}
|
||||
|
||||
export function chooseCpuHealActions({
|
||||
party,
|
||||
spells,
|
||||
behavior,
|
||||
preferSlots = false,
|
||||
}: {
|
||||
party: PartyMember[]
|
||||
spells: Spell[]
|
||||
behavior: CpuHealBehavior
|
||||
preferSlots?: boolean
|
||||
}) {
|
||||
let livingCount = 0
|
||||
let healthRatioTotal = 0
|
||||
let lowest: PartyMember | null = null
|
||||
let tank: PartyMember | null = null
|
||||
let cleanseTarget: PartyMember | null = null
|
||||
let renewTarget: PartyMember | null = null
|
||||
let shieldTarget: PartyMember | null = null
|
||||
let woundedCount = 0
|
||||
|
||||
for (const member of party) {
|
||||
if (member.health <= 0) continue
|
||||
livingCount += 1
|
||||
const ratio = member.health / effectiveMaxHealth(member)
|
||||
healthRatioTotal += ratio
|
||||
if (!lowest || ratio < lowest.health / effectiveMaxHealth(lowest)) lowest = member
|
||||
if (!tank && member.role === 'Tank') tank = member
|
||||
if (!cleanseTarget && (member.debuff || (member.poisonStacks ?? 0) > 0)) cleanseTarget = member
|
||||
if (!renewTarget && member.hotTicks <= 1 && ratio < behavior.hotThreshold) renewTarget = member
|
||||
if (!shieldTarget && member.role === 'Tank' && member.shield <= 5 && ratio < behavior.shieldThreshold) {
|
||||
shieldTarget = member
|
||||
}
|
||||
if (ratio < behavior.directHealThreshold) woundedCount += 1
|
||||
}
|
||||
|
||||
if (!lowest || livingCount === 0) return []
|
||||
const averageHealth = healthRatioTotal / livingCount
|
||||
const spellByKind = (kind: Spell['kind']) => spells.find((candidate) => candidate.kind === kind)
|
||||
const spellBySlot = (slot: string) => spells.find((candidate) => candidate.key === slot)
|
||||
const direct = preferSlots ? spellBySlot('1') : spellByKind('direct')
|
||||
const hot = preferSlots ? spellBySlot('2') : spellByKind('hot')
|
||||
const group = preferSlots ? spellBySlot('3') : spellByKind('group')
|
||||
const shield = preferSlots ? spellBySlot('4') : spellByKind('shield')
|
||||
const cleanse = preferSlots ? spellBySlot('5') : spellByKind('cleanse')
|
||||
const ordered: Array<{ spell: Spell | undefined; targetId: string | null }> = [
|
||||
{ spell: cleanseTarget ? cleanse : undefined, targetId: cleanseTarget?.id ?? null },
|
||||
{ spell: averageHealth < behavior.groupHealThreshold ? group : undefined, targetId: lowest.id },
|
||||
{ spell: shieldTarget ? shield : undefined, targetId: shieldTarget?.id ?? null },
|
||||
{ spell: woundedCount > 0 ? direct : undefined, targetId: lowest.id },
|
||||
{ spell: renewTarget ? hot : undefined, targetId: renewTarget?.id ?? null },
|
||||
{ spell: tank ? direct : undefined, targetId: tank?.id ?? null },
|
||||
]
|
||||
|
||||
return ordered
|
||||
.filter((action): action is { spell: Spell; targetId: string } => Boolean(action.spell && action.targetId))
|
||||
}
|
||||
|
||||
export function chooseCpuHealAction(options: Parameters<typeof chooseCpuHealActions>[0]) {
|
||||
return chooseCpuHealActions(options)[0] ?? null
|
||||
}
|
||||
|
||||
export function runCpuHealTurn<TSide>({
|
||||
side,
|
||||
elapsedTicks,
|
||||
spells,
|
||||
behavior,
|
||||
preferSlots = false,
|
||||
applySpell,
|
||||
}: {
|
||||
side: TSide & { party: PartyMember[] }
|
||||
elapsedTicks: number
|
||||
spells: Spell[]
|
||||
behavior: CpuTurnBehavior
|
||||
preferSlots?: boolean
|
||||
applySpell: (side: TSide, spell: Spell, targetId: string) => void
|
||||
}) {
|
||||
if (!shouldCpuAct({ elapsedTicks, behavior })) return false
|
||||
const action = chooseCpuHealAction({
|
||||
party: side.party,
|
||||
spells,
|
||||
behavior,
|
||||
preferSlots,
|
||||
})
|
||||
if (!action) return false
|
||||
applySpell(side, action.spell, action.targetId)
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { DualScreenCombatState } from '../dualScreen'
|
||||
|
||||
export function buildCombatDualScreenState(state: DualScreenCombatState): DualScreenCombatState {
|
||||
return state
|
||||
}
|
||||
|
||||
export function buildPvpRoguelikeDualScreenState({
|
||||
status,
|
||||
...state
|
||||
}: Omit<DualScreenCombatState, 'status'> & {
|
||||
status: DualScreenCombatState['status'] | 'queueing' | 'round-countdown'
|
||||
}): DualScreenCombatState {
|
||||
return {
|
||||
...state,
|
||||
status: status === 'queueing' || status === 'round-countdown' ? 'playing' : status,
|
||||
}
|
||||
}
|
||||
|
||||
export function buildStadiumDualScreenState({
|
||||
status,
|
||||
...state
|
||||
}: Omit<DualScreenCombatState, 'status' | 'targetGroup' | 'speedMultiplier'> & {
|
||||
status: DualScreenCombatState['status'] | 'queueing' | 'round-countdown' | 'shop'
|
||||
}): DualScreenCombatState {
|
||||
return {
|
||||
...state,
|
||||
status: status === 'queueing' || status === 'round-countdown'
|
||||
? 'playing'
|
||||
: status === 'shop'
|
||||
? 'upgrade-choice'
|
||||
: status,
|
||||
targetGroup: 0,
|
||||
speedMultiplier: 1,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { DungeonEncounter } from '../profile'
|
||||
import { chooseRandom } from './rules'
|
||||
|
||||
export function encounterThreat(encounter: DungeonEncounter) {
|
||||
return (
|
||||
encounter.maxHealth
|
||||
+ encounter.damage * 18
|
||||
+ encounter.tankDamage * 10
|
||||
+ encounter.partyDamage * 18
|
||||
)
|
||||
}
|
||||
|
||||
export function buildRoguelikeSegment<TMechanic extends string, TExtra extends object = object>({
|
||||
pool,
|
||||
stage,
|
||||
mechanics,
|
||||
trashCandidateCount,
|
||||
bossCandidateCount,
|
||||
healthScale,
|
||||
damageScale,
|
||||
partyDamageScale,
|
||||
idBase,
|
||||
bossDescription,
|
||||
bossName,
|
||||
fallbackBossFirst = false,
|
||||
extraFields,
|
||||
}: {
|
||||
pool: DungeonEncounter[]
|
||||
stage: number
|
||||
mechanics: TMechanic[]
|
||||
trashCandidateCount: (trashCount: number) => number
|
||||
bossCandidateCount: (bossCount: number) => number
|
||||
healthScale: number
|
||||
damageScale: number
|
||||
partyDamageScale: number
|
||||
idBase: number
|
||||
bossDescription: (mechanics: TMechanic[]) => string
|
||||
bossName?: (encounter: DungeonEncounter) => string
|
||||
fallbackBossFirst?: boolean
|
||||
extraFields?: (encounter: DungeonEncounter, isBoss: boolean, mechanics: TMechanic[]) => TExtra
|
||||
}) {
|
||||
const trashPool = [...pool.filter((encounter) => !encounter.isBoss)]
|
||||
.sort((left, right) => encounterThreat(left) - encounterThreat(right))
|
||||
const bossPool = [...pool.filter((encounter) => encounter.isBoss)]
|
||||
.sort((left, right) => encounterThreat(left) - encounterThreat(right))
|
||||
const selectedTrash = chooseRandom(trashPool.slice(0, trashCandidateCount(trashPool.length)), 2)
|
||||
const selectedBoss = chooseRandom(bossPool.slice(0, bossCandidateCount(bossPool.length)), 1)[0]
|
||||
?? (fallbackBossFirst ? bossPool[0] : trashPool[0])
|
||||
?? trashPool[0]
|
||||
?? pool[0]
|
||||
const selectedMechanics = mechanics
|
||||
|
||||
return [...selectedTrash, selectedBoss].map((encounter, index) => {
|
||||
const isBoss = index === 2
|
||||
return {
|
||||
...encounter,
|
||||
id: idBase + stage * 10 + index,
|
||||
sequence: (stage - 1) * 3 + index + 1,
|
||||
isBoss,
|
||||
encounterType: isBoss ? 'boss' : 'trash',
|
||||
enemyName: isBoss ? (bossName?.(encounter) ?? `${encounter.enemyName} ${stage}`) : encounter.enemyName,
|
||||
description: isBoss ? bossDescription(selectedMechanics) : encounter.description,
|
||||
maxHealth: Math.round(encounter.maxHealth * healthScale),
|
||||
damage: Math.round(encounter.damage * damageScale),
|
||||
tankDamage: Math.round(encounter.tankDamage * damageScale),
|
||||
partyDamage: Math.round(encounter.partyDamage * partyDamageScale),
|
||||
lootTables: [],
|
||||
...(extraFields?.(encounter, isBoss, selectedMechanics) ?? {} as TExtra),
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { PartyMember } from '../game'
|
||||
|
||||
export type PveCombatState = {
|
||||
party: PartyMember[]
|
||||
resource: number
|
||||
enemyHealth: number
|
||||
cooldowns: Record<string, number>
|
||||
elapsedTicks: number
|
||||
castsTowardFree: number
|
||||
freeCastReady: boolean
|
||||
}
|
||||
|
||||
export function createPveCombatState<TEncounter extends { maxHealth: number }>({
|
||||
partyTemplate,
|
||||
maxResource,
|
||||
encounter,
|
||||
enemyCount,
|
||||
}: {
|
||||
partyTemplate: readonly PartyMember[]
|
||||
maxResource: number
|
||||
encounter: TEncounter
|
||||
enemyCount: number
|
||||
}): PveCombatState {
|
||||
return {
|
||||
party: partyTemplate.map((member) => ({ ...member })),
|
||||
resource: maxResource,
|
||||
enemyHealth: encounter.maxHealth * enemyCount,
|
||||
cooldowns: {},
|
||||
elapsedTicks: 0,
|
||||
castsTowardFree: 0,
|
||||
freeCastReady: false,
|
||||
}
|
||||
}
|
||||
|
||||
export function createPveRoguelikeRunStart<TEncounter extends { maxHealth: number }>({
|
||||
partyTemplate,
|
||||
maxResource,
|
||||
encounters,
|
||||
initialEncounterIndex,
|
||||
enemyCount,
|
||||
}: {
|
||||
partyTemplate: readonly PartyMember[]
|
||||
maxResource: number
|
||||
encounters: readonly TEncounter[]
|
||||
initialEncounterIndex: number
|
||||
enemyCount: number
|
||||
}) {
|
||||
return {
|
||||
combatState: createPveCombatState({
|
||||
partyTemplate,
|
||||
maxResource,
|
||||
encounter: encounters[initialEncounterIndex],
|
||||
enemyCount,
|
||||
}),
|
||||
defaults: {
|
||||
roguelikeStage: 1,
|
||||
selectedId: partyTemplate[0].id,
|
||||
encounterIndex: initialEncounterIndex,
|
||||
status: 'playing' as const,
|
||||
paused: false,
|
||||
targetGroup: 0 as const,
|
||||
reward: null,
|
||||
rewardError: '',
|
||||
lootRolls: [],
|
||||
showEndLog: false,
|
||||
floatingTexts: [],
|
||||
roguelikeUpgrades: [],
|
||||
upgradeChoices: [],
|
||||
marathonBossesDefeated: 0,
|
||||
resourceSpent: 0,
|
||||
log: { text: 'A new run begins.', tone: 'system' as const },
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
export type PvpRoguelikeStatus = 'queueing' | 'round-countdown' | 'playing' | 'upgrade-choice' | 'won' | 'lost'
|
||||
export type PvpRoguelikeLogTone = 'system' | 'heal' | 'danger' | 'loot'
|
||||
|
||||
export type PvpRoguelikeCombatOutcome =
|
||||
| {
|
||||
type: 'none'
|
||||
}
|
||||
| {
|
||||
type: 'lost'
|
||||
status: 'lost'
|
||||
log: { text: string; tone: PvpRoguelikeLogTone }
|
||||
}
|
||||
| {
|
||||
type: 'won'
|
||||
status: 'won'
|
||||
markOpponentDefeated: boolean
|
||||
log: { text: string; tone: PvpRoguelikeLogTone }
|
||||
}
|
||||
| {
|
||||
type: 'upgrade-choice'
|
||||
status: 'upgrade-choice'
|
||||
log: { text: string; tone: PvpRoguelikeLogTone }
|
||||
}
|
||||
|
||||
export function resolvePvpRoguelikeCombatOutcome({
|
||||
playerAlive,
|
||||
opponentAlive,
|
||||
playerCleared,
|
||||
encounterIsBoss,
|
||||
encounterName,
|
||||
opponentDefeated,
|
||||
liveMatchActive,
|
||||
opponentLabel,
|
||||
}: {
|
||||
playerAlive: boolean
|
||||
opponentAlive: boolean
|
||||
playerCleared: boolean
|
||||
encounterIsBoss: boolean
|
||||
encounterName: string
|
||||
opponentDefeated: boolean
|
||||
liveMatchActive: boolean
|
||||
opponentLabel: string
|
||||
}): PvpRoguelikeCombatOutcome {
|
||||
if (!playerAlive) {
|
||||
return {
|
||||
type: 'lost',
|
||||
status: 'lost',
|
||||
log: { text: 'Your party fell first.', tone: 'danger' },
|
||||
}
|
||||
}
|
||||
if (!liveMatchActive && !opponentAlive && !opponentDefeated) {
|
||||
return {
|
||||
type: 'won',
|
||||
status: 'won',
|
||||
markOpponentDefeated: true,
|
||||
log: { text: `${opponentLabel} fell. Match complete.`, tone: 'loot' },
|
||||
}
|
||||
}
|
||||
if (!playerCleared) return { type: 'none' }
|
||||
if (encounterIsBoss && opponentDefeated) {
|
||||
return {
|
||||
type: 'won',
|
||||
status: 'won',
|
||||
markOpponentDefeated: false,
|
||||
log: { text: `${opponentLabel} defeated. Match complete.`, tone: 'loot' },
|
||||
}
|
||||
}
|
||||
return {
|
||||
type: 'upgrade-choice',
|
||||
status: 'upgrade-choice',
|
||||
log: { text: `${encounterName} cleared. Choose your next edge.`, tone: 'loot' },
|
||||
}
|
||||
}
|
||||
|
||||
export function resolvePvpRoguelikeLiveSnapshot({
|
||||
currentStatus,
|
||||
opponentStatus,
|
||||
opponentAlive,
|
||||
alreadyLoggedOpponentDone,
|
||||
opponentName,
|
||||
}: {
|
||||
currentStatus: PvpRoguelikeStatus
|
||||
opponentStatus?: string
|
||||
opponentAlive?: boolean
|
||||
alreadyLoggedOpponentDone: boolean
|
||||
opponentName: string
|
||||
}) {
|
||||
if (currentStatus === 'won' || currentStatus === 'lost') return { type: 'none' as const }
|
||||
if ((opponentStatus === 'lost' || opponentAlive === false) && !alreadyLoggedOpponentDone) {
|
||||
return {
|
||||
type: 'won' as const,
|
||||
status: 'won' as const,
|
||||
markOpponentDefeated: true,
|
||||
log: { text: `${opponentName} fell. Match complete.`, tone: 'loot' as const },
|
||||
}
|
||||
}
|
||||
if (opponentStatus === 'won') {
|
||||
return {
|
||||
type: 'lost' as const,
|
||||
status: 'lost' as const,
|
||||
log: { text: `${opponentName} finished first.`, tone: 'danger' as const },
|
||||
}
|
||||
}
|
||||
return { type: 'none' as const }
|
||||
}
|
||||
|
||||
export function resolvePvpRoguelikeUpgradeCompletion({
|
||||
clearedBoss,
|
||||
opponentDefeated,
|
||||
opponentLabel,
|
||||
}: {
|
||||
clearedBoss: boolean
|
||||
opponentDefeated: boolean
|
||||
opponentLabel: string
|
||||
}) {
|
||||
if (!clearedBoss || !opponentDefeated) return { type: 'none' as const }
|
||||
return {
|
||||
type: 'won' as const,
|
||||
status: 'won' as const,
|
||||
log: { text: `${opponentLabel} defeated. Match complete.`, tone: 'loot' as const },
|
||||
}
|
||||
}
|
||||
|
||||
export function nextPvpRoguelikeStage<TEncounter>({
|
||||
clearedBoss,
|
||||
stage,
|
||||
encounterIndex,
|
||||
encounters,
|
||||
nextSegment,
|
||||
}: {
|
||||
clearedBoss: boolean
|
||||
stage: number
|
||||
encounterIndex: number
|
||||
encounters: readonly TEncounter[]
|
||||
nextSegment: readonly TEncounter[]
|
||||
}) {
|
||||
const nextStage = clearedBoss ? stage + 1 : stage
|
||||
const nextEncounter = clearedBoss ? nextSegment[0] : encounters[encounterIndex + 1]
|
||||
if (!nextEncounter) {
|
||||
return {
|
||||
type: 'complete' as const,
|
||||
status: 'won' as const,
|
||||
log: { text: 'No further encounters remain.', tone: 'loot' as const },
|
||||
}
|
||||
}
|
||||
return {
|
||||
type: 'next' as const,
|
||||
nextStage,
|
||||
nextEncounter,
|
||||
nextEncounterIndex: encounterIndex + 1,
|
||||
appendSegment: clearedBoss,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import type { PartyMember } from '../game'
|
||||
import type { PvpMatchSnapshot, PvpMatchSide } from '../pvpRoguelike'
|
||||
|
||||
export type PvpRoguelikeSetupSide<TBuff extends string, TDebuff extends string> = {
|
||||
party: PartyMember[]
|
||||
resource: number
|
||||
cooldowns: Record<string, number>
|
||||
enemyHealth: number
|
||||
buffs: TBuff[]
|
||||
debuffs: TDebuff[]
|
||||
castsTowardFree: number
|
||||
freeCastReady: boolean
|
||||
}
|
||||
|
||||
export type PvpRoguelikeLiveMatchSetup = {
|
||||
id: string
|
||||
side: PvpMatchSide
|
||||
opponentSide: PvpMatchSide
|
||||
opponentName: string
|
||||
opponentClassName: string
|
||||
}
|
||||
|
||||
export function createPvpRoguelikeStarterSide<TBuff extends string, TDebuff extends string>(
|
||||
partyTemplate: readonly PartyMember[],
|
||||
maxResource: number,
|
||||
): PvpRoguelikeSetupSide<TBuff, TDebuff> {
|
||||
return {
|
||||
party: partyTemplate.map((member) => ({ ...member })),
|
||||
resource: maxResource,
|
||||
cooldowns: {},
|
||||
enemyHealth: 0,
|
||||
buffs: [],
|
||||
debuffs: [],
|
||||
castsTowardFree: 0,
|
||||
freeCastReady: false,
|
||||
}
|
||||
}
|
||||
|
||||
export function createPvpRoguelikeMatchStart<
|
||||
TBuff extends string,
|
||||
TDebuff extends string,
|
||||
TEncounter extends { maxHealth: number },
|
||||
>({
|
||||
startStage,
|
||||
encounterPool,
|
||||
buildSegment,
|
||||
partyTemplate,
|
||||
opponentPartyTemplate,
|
||||
maxResource,
|
||||
upgradeChoiceSeconds,
|
||||
}: {
|
||||
startStage: number
|
||||
encounterPool: readonly TEncounter[]
|
||||
buildSegment: (pool: readonly TEncounter[], stage: number) => TEncounter[]
|
||||
partyTemplate: readonly PartyMember[]
|
||||
opponentPartyTemplate: readonly PartyMember[]
|
||||
maxResource: number
|
||||
upgradeChoiceSeconds: number
|
||||
}) {
|
||||
const firstSegment = buildSegment(encounterPool, startStage)
|
||||
const firstEncounter = firstSegment[0]
|
||||
const playerSide = createPvpRoguelikeStarterSide<TBuff, TDebuff>(partyTemplate, maxResource)
|
||||
const opponentSide = createPvpRoguelikeStarterSide<TBuff, TDebuff>(opponentPartyTemplate, maxResource)
|
||||
playerSide.enemyHealth = firstEncounter.maxHealth
|
||||
opponentSide.enemyHealth = firstEncounter.maxHealth
|
||||
|
||||
return {
|
||||
startStage,
|
||||
firstSegment,
|
||||
firstEncounter,
|
||||
playerSide,
|
||||
opponentSide,
|
||||
defaults: {
|
||||
encounterIndex: 0,
|
||||
elapsedTicks: 0,
|
||||
encountersCleared: 0,
|
||||
paused: false,
|
||||
targetGroup: 0 as const,
|
||||
upgradeTimeLeft: upgradeChoiceSeconds,
|
||||
liveUpgradePending: false,
|
||||
rewardError: '',
|
||||
showEndLog: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function createPvpRoguelikeLiveMatchStart<
|
||||
TBuff extends string,
|
||||
TDebuff extends string,
|
||||
TEncounter extends { maxHealth: number },
|
||||
>({
|
||||
match,
|
||||
side,
|
||||
encounterPool,
|
||||
buildSegment,
|
||||
partyTemplate,
|
||||
opponentPartyTemplate,
|
||||
maxResource,
|
||||
upgradeChoiceSeconds,
|
||||
message,
|
||||
}: {
|
||||
match: PvpMatchSnapshot<PvpRoguelikeSetupSide<TBuff, TDebuff>>
|
||||
side: PvpMatchSide
|
||||
encounterPool: readonly TEncounter[]
|
||||
buildSegment: (pool: readonly TEncounter[], stage: number) => TEncounter[]
|
||||
partyTemplate: readonly PartyMember[]
|
||||
opponentPartyTemplate: readonly PartyMember[]
|
||||
maxResource: number
|
||||
upgradeChoiceSeconds: number
|
||||
message?: string
|
||||
}) {
|
||||
const opponentSideId: PvpMatchSide = side === 'a' ? 'b' : 'a'
|
||||
const opponent = match.players[opponentSideId]
|
||||
const opponentTemplate = opponentPartyTemplate.map((member) => ({
|
||||
...member,
|
||||
name: member.id === 'mira' ? opponent.characterName : member.name,
|
||||
}))
|
||||
const setup = createPvpRoguelikeMatchStart<TBuff, TDebuff, TEncounter>({
|
||||
startStage: match.startStage,
|
||||
encounterPool,
|
||||
buildSegment,
|
||||
partyTemplate,
|
||||
opponentPartyTemplate: opponentTemplate,
|
||||
maxResource,
|
||||
upgradeChoiceSeconds,
|
||||
})
|
||||
const liveMatch: PvpRoguelikeLiveMatchSetup = {
|
||||
id: match.id,
|
||||
side,
|
||||
opponentSide: opponentSideId,
|
||||
opponentName: opponent.characterName,
|
||||
opponentClassName: opponent.className,
|
||||
}
|
||||
|
||||
return {
|
||||
...setup,
|
||||
liveMatch,
|
||||
logText: message ?? `${opponent.characterName} found. Stage ${match.startStage} begins.`,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { PartyMember } from '../game'
|
||||
|
||||
export type PvpRoguelikeUpgradeSide<TBuff extends string, TDebuff extends string> = {
|
||||
party: PartyMember[]
|
||||
resource: number
|
||||
cooldowns: Record<string, number>
|
||||
enemyHealth: number
|
||||
buffs: TBuff[]
|
||||
debuffs: TDebuff[]
|
||||
}
|
||||
|
||||
function removeRandomItem<T>(items: readonly T[], random = Math.random) {
|
||||
if (items.length === 0) return [...items]
|
||||
const removedIndex = Math.floor(random() * items.length)
|
||||
return items.filter((_, index) => index !== removedIndex)
|
||||
}
|
||||
|
||||
export function recoverPartyForNextPvpRoguelikeEncounter(party: readonly PartyMember[]) {
|
||||
return party.map((member) => ({
|
||||
...member,
|
||||
health: member.maxHealth,
|
||||
debuff: undefined,
|
||||
debuffTicks: undefined,
|
||||
poisonStacks: undefined,
|
||||
maxHealthPenaltyTicks: undefined,
|
||||
healingReductionTicks: undefined,
|
||||
}))
|
||||
}
|
||||
|
||||
export function applyPvpRoguelikeUpgradeChoice<
|
||||
TBuff extends string,
|
||||
TDebuff extends string,
|
||||
TSide extends PvpRoguelikeUpgradeSide<TBuff, TDebuff>,
|
||||
>({
|
||||
side,
|
||||
buffId,
|
||||
incomingDebuffId,
|
||||
reviveBuffId,
|
||||
random,
|
||||
}: {
|
||||
side: TSide
|
||||
buffId: TBuff
|
||||
incomingDebuffId?: TDebuff
|
||||
reviveBuffId: TBuff
|
||||
random?: () => number
|
||||
}) {
|
||||
const reviveSelected = buffId === reviveBuffId
|
||||
const nextBuffs = reviveSelected ? side.buffs : [...side.buffs, buffId]
|
||||
const nextDebuffs = incomingDebuffId ? [...side.debuffs, incomingDebuffId] : side.debuffs
|
||||
return {
|
||||
...side,
|
||||
buffs: nextBuffs,
|
||||
debuffs: reviveSelected ? removeRandomItem(nextDebuffs, random) : nextDebuffs,
|
||||
}
|
||||
}
|
||||
|
||||
export function preparePvpRoguelikeNextEncounter<
|
||||
TBuff extends string,
|
||||
TDebuff extends string,
|
||||
TSide extends PvpRoguelikeUpgradeSide<TBuff, TDebuff>,
|
||||
>({
|
||||
side,
|
||||
maxResource,
|
||||
enemyMaxHealth,
|
||||
}: {
|
||||
side: TSide
|
||||
maxResource: number
|
||||
enemyMaxHealth: number
|
||||
}) {
|
||||
return {
|
||||
...side,
|
||||
party: recoverPartyForNextPvpRoguelikeEncounter(side.party),
|
||||
resource: maxResource,
|
||||
cooldowns: {},
|
||||
enemyHealth: enemyMaxHealth,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { PartyMember, Spell } from '../game'
|
||||
import { applyCastStateUpdate, type CastStateFields } from './combatStateTransitions'
|
||||
import { canCastSpell } from './spellCasting'
|
||||
import { applySpellEffectProfile, type SpellEffectProfile, type SpellTargetPlan } from './spellEffects'
|
||||
|
||||
export function applyPvpSpellCast<TState extends CastStateFields<PartyMember[]>>({
|
||||
current,
|
||||
spell,
|
||||
targetId,
|
||||
resourceCost,
|
||||
targetPlan,
|
||||
profile,
|
||||
setCurrent,
|
||||
emitFloatingHeal,
|
||||
cooldownMultiplier = 1,
|
||||
cooldowns,
|
||||
freeCast,
|
||||
}: {
|
||||
current: TState
|
||||
spell: Spell
|
||||
targetId: string
|
||||
resourceCost: number
|
||||
targetPlan: SpellTargetPlan
|
||||
profile: SpellEffectProfile
|
||||
setCurrent: (next: TState) => void
|
||||
emitFloatingHeal: (memberId: string, value: number) => void
|
||||
cooldownMultiplier?: number
|
||||
cooldowns?: Record<string, number>
|
||||
freeCast?: {
|
||||
enabled: boolean
|
||||
wasReady: boolean
|
||||
}
|
||||
}) {
|
||||
if (!canCastSpell(spell, current.resource, current.cooldowns, resourceCost)) return false
|
||||
const target = current.party.find((member) => member.id === targetId && member.health > 0)
|
||||
if (!target) return false
|
||||
|
||||
const { party: nextParty, floatingHeals } = applySpellEffectProfile({
|
||||
party: current.party,
|
||||
spell,
|
||||
targetId,
|
||||
plan: targetPlan,
|
||||
profile,
|
||||
})
|
||||
floatingHeals.forEach((event) => emitFloatingHeal(event.memberId, event.value))
|
||||
|
||||
const nextState = applyCastStateUpdate({
|
||||
current,
|
||||
party: nextParty,
|
||||
cooldowns,
|
||||
spell,
|
||||
resourceCost,
|
||||
cooldownMultiplier,
|
||||
freeCast,
|
||||
})
|
||||
setCurrent(nextState)
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { DungeonReward } from '../profile'
|
||||
|
||||
export type RewardSummaryBase = {
|
||||
experienceGained: number
|
||||
previousLevel: number | null
|
||||
newLevel: number | null
|
||||
levelsGained: number
|
||||
talentPointsGained: number
|
||||
unlockedAbilities: DungeonReward['unlockedAbilities']
|
||||
}
|
||||
|
||||
export type PvpRunRewardSummary = RewardSummaryBase & {
|
||||
bossesKilled: number
|
||||
loot: Array<NonNullable<DungeonReward['bonusItem']>>
|
||||
}
|
||||
|
||||
export function createEmptyRewardSummary(): RewardSummaryBase {
|
||||
return {
|
||||
experienceGained: 0,
|
||||
previousLevel: null,
|
||||
newLevel: null,
|
||||
levelsGained: 0,
|
||||
talentPointsGained: 0,
|
||||
unlockedAbilities: [],
|
||||
}
|
||||
}
|
||||
|
||||
export function createEmptyPvpRunSummary(): PvpRunRewardSummary {
|
||||
return {
|
||||
bossesKilled: 0,
|
||||
...createEmptyRewardSummary(),
|
||||
loot: [],
|
||||
}
|
||||
}
|
||||
|
||||
export function mergeUnlockedAbilities(
|
||||
current: DungeonReward['unlockedAbilities'],
|
||||
next: DungeonReward['unlockedAbilities'],
|
||||
) {
|
||||
const unlockedById = new Map(current.map((ability) => [ability.id, ability]))
|
||||
next.forEach((ability) => unlockedById.set(ability.id, ability))
|
||||
return Array.from(unlockedById.values())
|
||||
}
|
||||
|
||||
export function mergeDungeonRewardSummary<TSummary extends RewardSummaryBase>(
|
||||
current: TSummary,
|
||||
reward: DungeonReward,
|
||||
) {
|
||||
return {
|
||||
...current,
|
||||
experienceGained: current.experienceGained + reward.experienceGained,
|
||||
previousLevel: current.previousLevel ?? reward.previousLevel,
|
||||
newLevel: reward.newLevel,
|
||||
levelsGained: current.levelsGained + reward.levelsGained,
|
||||
talentPointsGained: current.talentPointsGained + reward.talentPointsGained,
|
||||
unlockedAbilities: mergeUnlockedAbilities(current.unlockedAbilities, reward.unlockedAbilities),
|
||||
}
|
||||
}
|
||||
|
||||
export function mergePvpRunRewardSummary(
|
||||
current: PvpRunRewardSummary,
|
||||
reward: DungeonReward,
|
||||
{ bossKilled }: { bossKilled: boolean },
|
||||
) {
|
||||
return {
|
||||
...mergeDungeonRewardSummary(current, reward),
|
||||
bossesKilled: current.bossesKilled + (bossKilled ? 1 : 0),
|
||||
loot: reward.bonusItem ? [...current.loot, reward.bonusItem] : current.loot,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { Spell } from '../game'
|
||||
import { createStackCounts, summarizeStackCounts } from './stackCounts'
|
||||
|
||||
export type UpgradeChoice<T extends string> = {
|
||||
id: T
|
||||
name: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export function slotLabel(slot: string, spells: Spell[], labelMode: 'ability' | 'slot') {
|
||||
const spell = spells.find((candidate) => candidate.key === slot)
|
||||
if (labelMode === 'ability' && spell) return spell.name
|
||||
return `Slot ${slot}`
|
||||
}
|
||||
|
||||
export function buildSelfSlotUpgradeChoices<T extends string>({
|
||||
slots,
|
||||
spells,
|
||||
labelMode,
|
||||
idPrefix = 'slot',
|
||||
}: {
|
||||
slots: readonly string[]
|
||||
spells: Spell[]
|
||||
labelMode: 'ability' | 'slot'
|
||||
idPrefix?: string
|
||||
}) {
|
||||
return slots.flatMap((slot) => {
|
||||
const label = slotLabel(slot, spells, labelMode)
|
||||
return [
|
||||
{
|
||||
id: `${idPrefix}${slot}-extra-target` as T,
|
||||
name: `${label}: +1 target`,
|
||||
description: `${label} affects 1 additional ally when possible.`,
|
||||
},
|
||||
{
|
||||
id: `${idPrefix}${slot}-cost-down` as T,
|
||||
name: `${label}: -25% cost`,
|
||||
description: `${label} costs 25% less resource.`,
|
||||
},
|
||||
{
|
||||
id: `${idPrefix}${slot}-cooldown-down` as T,
|
||||
name: `${label}: -25% cooldown`,
|
||||
description: `${label} recharges 25% faster.`,
|
||||
},
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
export function buildOpponentSlotDebuffChoices<T extends string>({
|
||||
slots,
|
||||
spells,
|
||||
labelMode,
|
||||
}: {
|
||||
slots: readonly string[]
|
||||
spells: Spell[]
|
||||
labelMode: 'ability' | 'slot'
|
||||
}) {
|
||||
return slots.flatMap((slot) => {
|
||||
const label = slotLabel(slot, spells, labelMode)
|
||||
return [
|
||||
{
|
||||
id: `opp-slot${slot}-cost-up` as T,
|
||||
name: `${label}: +25% cost`,
|
||||
description: `Opponent ${label.toLowerCase()} costs 25% more resource.`,
|
||||
},
|
||||
{
|
||||
id: `opp-slot${slot}-cooldown-up` as T,
|
||||
name: `${label}: +25% cooldown`,
|
||||
description: `Opponent ${label.toLowerCase()} recharges 25% slower.`,
|
||||
},
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
export function summarizeChoiceStacks<T extends string>(
|
||||
items: T[],
|
||||
catalog: Array<UpgradeChoice<T>>,
|
||||
emptyLabel = '',
|
||||
) {
|
||||
return summarizeStackCounts(createStackCounts(items), catalog, emptyLabel)
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import type { PartyMember, Spell } from '../game'
|
||||
import type { Ability } from '../profile'
|
||||
import { createStackCounts, stackCount } from './stackCounts'
|
||||
|
||||
export const DEFAULT_TICK_MS = 700
|
||||
|
||||
export function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, value))
|
||||
}
|
||||
|
||||
export function chooseRandom<T>(items: T[], count: number) {
|
||||
const pool = [...items]
|
||||
const result: T[] = []
|
||||
while (pool.length > 0 && result.length < count) {
|
||||
const index = Math.floor(Math.random() * pool.length)
|
||||
result.push(pool.splice(index, 1)[0])
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function effectiveMaxHealth(member: PartyMember) {
|
||||
return Math.max(
|
||||
1,
|
||||
Math.round(member.maxHealth * (member.maxHealthPenaltyTicks && member.maxHealthPenaltyTicks > 0 ? 0.75 : 1)),
|
||||
)
|
||||
}
|
||||
|
||||
export function healAmount(member: PartyMember, amount: number, multiplier = 1) {
|
||||
return Math.round(amount * (member.healingReductionTicks && member.healingReductionTicks > 0 ? 0.75 : 1) * multiplier)
|
||||
}
|
||||
|
||||
export function healMember(member: PartyMember, amount: number, multiplier = 1) {
|
||||
return clamp(member.health + healAmount(member, amount, multiplier), 0, effectiveMaxHealth(member))
|
||||
}
|
||||
|
||||
export function memberHotEffects(member: PartyMember) {
|
||||
if (member.hotEffects?.length) return member.hotEffects
|
||||
return member.hotTicks > 0
|
||||
? [{ id: 'legacy-renew', spellId: 'legacy-renew', label: 'Renew', ticks: member.hotTicks, power: 6 }]
|
||||
: []
|
||||
}
|
||||
|
||||
export function effectId(prefix: string) {
|
||||
return `${prefix}-${globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random()}`}`
|
||||
}
|
||||
|
||||
export function addHotEffect(member: PartyMember, spell: Spell, ticks = 5) {
|
||||
const nextEffect = {
|
||||
id: effectId(spell.id),
|
||||
spellId: spell.id,
|
||||
label: spell.name,
|
||||
ticks,
|
||||
power: Math.max(1, Math.round(spell.power / 2)),
|
||||
}
|
||||
const currentEffects = memberHotEffects(member).filter((effect) => effect.spellId !== spell.id)
|
||||
return [...currentEffects, nextEffect]
|
||||
}
|
||||
|
||||
export function addBounceHeal(member: PartyMember, spell: Spell) {
|
||||
return [
|
||||
...(member.bounceHeals ?? []),
|
||||
{
|
||||
id: effectId(spell.id),
|
||||
label: spell.name,
|
||||
charges: 4,
|
||||
power: spell.power,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
export function tickHotEffects(effects: PartyMember['hotEffects']) {
|
||||
return (effects ?? [])
|
||||
.map((effect) => ({ ...effect, ticks: effect.ticks - 1 }))
|
||||
.filter((effect) => effect.ticks > 0)
|
||||
}
|
||||
|
||||
export function formatEffectTime(ticks: number, tickMs = DEFAULT_TICK_MS) {
|
||||
const seconds = (ticks * tickMs) / 1000
|
||||
return Number.isInteger(seconds) ? `${seconds}s` : `${seconds.toFixed(1)}s`
|
||||
}
|
||||
|
||||
export function buffStacks<T extends string>(items: readonly T[], id: T) {
|
||||
return stackCount(createStackCounts(items), id)
|
||||
}
|
||||
|
||||
export function createLogEntry(nextLogId: { current: number }, text: string, tone: 'system' | 'heal' | 'danger' | 'loot') {
|
||||
return { id: nextLogId.current++, text, tone }
|
||||
}
|
||||
|
||||
export function toCombatSpell(ability: Ability, key: string, healingPower = 0): Spell {
|
||||
const kinds: Record<string, Spell['kind']> = {
|
||||
direct_heal: 'direct',
|
||||
direct_hot: 'direct',
|
||||
heal_over_time: 'hot',
|
||||
party_heal: 'group',
|
||||
party_hot: 'group',
|
||||
party_absorb: 'group',
|
||||
absorb: 'shield',
|
||||
damage_reduction: 'damage_reduction',
|
||||
bounce_heal: 'bounce_heal',
|
||||
cleanse: 'cleanse',
|
||||
}
|
||||
return {
|
||||
id: String(ability.id),
|
||||
key,
|
||||
name: ability.name,
|
||||
description: ability.description,
|
||||
cost: ability.cost,
|
||||
cooldown: ability.cooldown,
|
||||
power: ability.power + healingPower,
|
||||
glyph: ability.glyph,
|
||||
kind: kinds[ability.spellType] ?? 'direct',
|
||||
effectType: ability.spellType,
|
||||
}
|
||||
}
|
||||
|
||||
export function resetParty(partyTemplate: PartyMember[]) {
|
||||
return partyTemplate.map((member) => ({
|
||||
...member,
|
||||
health: member.maxHealth,
|
||||
shield: 0,
|
||||
hotTicks: 0,
|
||||
hotEffects: undefined,
|
||||
debuff: undefined,
|
||||
debuffTicks: undefined,
|
||||
poisonStacks: undefined,
|
||||
maxHealthPenaltyTicks: undefined,
|
||||
healingReductionTicks: undefined,
|
||||
damageReductionTicks: undefined,
|
||||
bounceHeals: undefined,
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { Spell } from '../game'
|
||||
|
||||
export function canCastSpell(
|
||||
spell: Spell,
|
||||
resource: number,
|
||||
cooldowns: Record<string, number>,
|
||||
resourceCost: number,
|
||||
) {
|
||||
return resource >= resourceCost && (cooldowns[spell.id] ?? 0) <= 0
|
||||
}
|
||||
|
||||
export function putSpellOnCooldown(
|
||||
cooldowns: Record<string, number>,
|
||||
spell: Spell,
|
||||
cooldownMultiplier = 1,
|
||||
) {
|
||||
return {
|
||||
...cooldowns,
|
||||
[spell.id]: spell.cooldown * cooldownMultiplier,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
import { DEFAULT_GROUP_HEAL_TARGETS, groupHealTargets, type PartyMember, type Spell } from '../game'
|
||||
import { addBounceHeal, addHotEffect } from './rules'
|
||||
|
||||
export type SpellTargetBucket = 'direct' | 'hot' | 'shield' | 'damageReduction'
|
||||
|
||||
export type SpellTargetPlan = {
|
||||
directTargets: Set<string>
|
||||
hotTargets: Set<string>
|
||||
shieldTargets: Set<string>
|
||||
damageReductionTargets: Set<string>
|
||||
groupTargets: Set<string>
|
||||
}
|
||||
|
||||
export type ExtraTargetMode = Partial<Record<Spell['kind'], SpellTargetBucket>>
|
||||
|
||||
function hasBlockedId(blockedIds: Iterable<string>, id: string) {
|
||||
if (blockedIds instanceof Set) return blockedIds.has(id)
|
||||
for (const blockedId of blockedIds) {
|
||||
if (blockedId === id) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function lowestHealthExtraTarget(party: PartyMember[], blockedIds: Iterable<string>) {
|
||||
let target: PartyMember | undefined
|
||||
let targetRatio = Number.POSITIVE_INFINITY
|
||||
for (const member of party) {
|
||||
if (member.health <= 0 || hasBlockedId(blockedIds, member.id)) continue
|
||||
const healthRatio = member.health / member.maxHealth
|
||||
if (healthRatio < targetRatio) {
|
||||
target = member
|
||||
targetRatio = healthRatio
|
||||
}
|
||||
}
|
||||
return target
|
||||
}
|
||||
|
||||
export function addLowestHealthExtraTarget(
|
||||
plan: SpellTargetPlan,
|
||||
party: PartyMember[],
|
||||
bucket: SpellTargetBucket,
|
||||
) {
|
||||
const targetSet = bucket === 'direct'
|
||||
? plan.directTargets
|
||||
: bucket === 'hot'
|
||||
? plan.hotTargets
|
||||
: bucket === 'shield'
|
||||
? plan.shieldTargets
|
||||
: plan.damageReductionTargets
|
||||
const extra = lowestHealthExtraTarget(party, targetSet)
|
||||
if (extra) targetSet.add(extra.id)
|
||||
}
|
||||
|
||||
export function buildSpellTargetPlan({
|
||||
party,
|
||||
spell,
|
||||
targetId,
|
||||
extraTargets,
|
||||
directTarget,
|
||||
hotTarget,
|
||||
shieldTarget,
|
||||
damageReductionTarget,
|
||||
groupTargetCount = DEFAULT_GROUP_HEAL_TARGETS,
|
||||
extraTargetMode,
|
||||
beforeExtraTargetBuckets = [],
|
||||
}: {
|
||||
party: PartyMember[]
|
||||
spell: Spell
|
||||
targetId: string
|
||||
extraTargets: number
|
||||
directTarget: boolean
|
||||
hotTarget: boolean
|
||||
shieldTarget: boolean
|
||||
damageReductionTarget?: boolean
|
||||
groupTargetCount?: number
|
||||
extraTargetMode: ExtraTargetMode
|
||||
beforeExtraTargetBuckets?: SpellTargetBucket[]
|
||||
}): SpellTargetPlan {
|
||||
const plan: SpellTargetPlan = {
|
||||
directTargets: new Set(directTarget ? [targetId] : []),
|
||||
hotTargets: new Set(hotTarget ? [targetId] : []),
|
||||
shieldTargets: new Set(shieldTarget ? [targetId] : []),
|
||||
damageReductionTargets: new Set(damageReductionTarget ? [targetId] : []),
|
||||
groupTargets: new Set(
|
||||
spell.kind === 'group'
|
||||
? groupHealTargets(party, groupTargetCount).map((member) => member.id)
|
||||
: [],
|
||||
),
|
||||
}
|
||||
|
||||
beforeExtraTargetBuckets.forEach((bucket) => {
|
||||
addLowestHealthExtraTarget(plan, party, bucket)
|
||||
})
|
||||
|
||||
const bucket = extraTargetMode[spell.kind] ?? 'direct'
|
||||
for (let index = 0; index < extraTargets; index += 1) {
|
||||
if (spell.kind === 'group') break
|
||||
addLowestHealthExtraTarget(plan, party, bucket)
|
||||
}
|
||||
|
||||
return plan
|
||||
}
|
||||
|
||||
export function clearNegativeEffects(member: PartyMember) {
|
||||
return {
|
||||
...member,
|
||||
debuff: undefined,
|
||||
debuffTicks: undefined,
|
||||
poisonStacks: undefined,
|
||||
maxHealthPenaltyTicks: undefined,
|
||||
healingReductionTicks: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export type FloatingHealEvent = {
|
||||
memberId: string
|
||||
value: number
|
||||
}
|
||||
|
||||
export type SpellEffectProfile = {
|
||||
/**
|
||||
* Profiles keep mode-specific spell math out of screen components. PvE uses
|
||||
* stacked HoT effect objects; PvP modes mostly use legacy hotTicks. Stadium
|
||||
* also applies dampening through the power callbacks below.
|
||||
*/
|
||||
modeName: string
|
||||
heal: (member: PartyMember, power: number, multiplier: number) => number
|
||||
healingMultiplier: (member: PartyMember) => number
|
||||
power: {
|
||||
direct: (spell: Spell) => number
|
||||
cleanse: (spell: Spell) => number
|
||||
groupHeal: (spell: Spell) => number
|
||||
groupAbsorb: (spell: Spell) => number
|
||||
shield: (sourcePower: number, strength?: number) => number
|
||||
}
|
||||
hot: {
|
||||
mode: 'effects' | 'ticks'
|
||||
defaultTicks: number
|
||||
groupTicks: number
|
||||
radianceTicks: number
|
||||
merge: 'replace' | 'max'
|
||||
groupMerge?: 'replace' | 'max'
|
||||
}
|
||||
effects: {
|
||||
renewSpell?: Spell | null
|
||||
shieldSpell?: Spell | null
|
||||
groupAbsorbOnly: (spell: Spell) => boolean
|
||||
groupHotOnly: (spell: Spell) => boolean
|
||||
groupAppliesShield: (spell: Spell) => boolean
|
||||
groupAppliesHot: (spell: Spell) => boolean
|
||||
shieldAppliesHot: (spell: Spell) => boolean
|
||||
hotSpellForDirect: (spell: Spell) => Spell
|
||||
}
|
||||
ratios: {
|
||||
groupShield: number
|
||||
directShield: number
|
||||
}
|
||||
damageReductionTicks: number
|
||||
floatingHeals: {
|
||||
group: boolean
|
||||
direct: boolean
|
||||
cleanse: boolean
|
||||
}
|
||||
bounceHeals: boolean
|
||||
}
|
||||
|
||||
function applyHot(
|
||||
member: PartyMember,
|
||||
spell: Spell,
|
||||
profile: SpellEffectProfile,
|
||||
ticks = profile.hot.defaultTicks,
|
||||
merge = profile.hot.merge,
|
||||
) {
|
||||
if (profile.hot.mode === 'effects') {
|
||||
return {
|
||||
...member,
|
||||
hotTicks: 0,
|
||||
hotEffects: addHotEffect(member, spell, ticks),
|
||||
}
|
||||
}
|
||||
return {
|
||||
...member,
|
||||
hotTicks: merge === 'max'
|
||||
? Math.max(member.hotTicks, ticks)
|
||||
: ticks,
|
||||
}
|
||||
}
|
||||
|
||||
function recordFloatingHeal(
|
||||
events: FloatingHealEvent[],
|
||||
enabled: boolean,
|
||||
member: PartyMember,
|
||||
nextHealth: number,
|
||||
) {
|
||||
if (enabled && nextHealth > member.health) {
|
||||
events.push({ memberId: member.id, value: nextHealth - member.health })
|
||||
}
|
||||
}
|
||||
|
||||
export function applySpellEffectProfile({
|
||||
party,
|
||||
spell,
|
||||
targetId,
|
||||
plan,
|
||||
profile,
|
||||
}: {
|
||||
party: PartyMember[]
|
||||
spell: Spell
|
||||
targetId: string
|
||||
plan: SpellTargetPlan
|
||||
profile: SpellEffectProfile
|
||||
}) {
|
||||
const floatingHeals: FloatingHealEvent[] = []
|
||||
const nextParty = party.map((member) => {
|
||||
if (member.health <= 0) return member
|
||||
|
||||
if (spell.kind === 'group') {
|
||||
if (!plan.groupTargets.has(member.id)) return member
|
||||
const isGroupAbsorb = profile.effects.groupAbsorbOnly(spell)
|
||||
if (profile.effects.groupHotOnly(spell)) {
|
||||
return applyHot(member, spell, profile, profile.hot.groupTicks, profile.hot.groupMerge)
|
||||
}
|
||||
|
||||
const power = isGroupAbsorb
|
||||
? profile.power.groupAbsorb(spell)
|
||||
: profile.power.groupHeal(spell)
|
||||
const nextHealth = isGroupAbsorb
|
||||
? member.health
|
||||
: profile.heal(member, power, profile.healingMultiplier(member))
|
||||
recordFloatingHeal(floatingHeals, profile.floatingHeals.group, member, nextHealth)
|
||||
|
||||
let nextMember: PartyMember = {
|
||||
...member,
|
||||
health: nextHealth,
|
||||
shield: profile.effects.groupAppliesShield(spell)
|
||||
? Math.max(
|
||||
member.shield,
|
||||
isGroupAbsorb
|
||||
? power
|
||||
: profile.power.shield(
|
||||
profile.effects.shieldSpell?.power ?? spell.power,
|
||||
profile.ratios.groupShield,
|
||||
),
|
||||
)
|
||||
: member.shield,
|
||||
}
|
||||
if (profile.effects.groupAppliesHot(spell) && profile.effects.renewSpell) {
|
||||
nextMember = applyHot(nextMember, profile.effects.renewSpell, profile, profile.hot.radianceTicks, profile.hot.groupMerge)
|
||||
} else if (profile.effects.groupAppliesHot(spell)) {
|
||||
nextMember = applyHot(nextMember, spell, profile, profile.hot.groupTicks, profile.hot.groupMerge)
|
||||
}
|
||||
return nextMember
|
||||
}
|
||||
|
||||
if (
|
||||
!plan.directTargets.has(member.id)
|
||||
&& !plan.hotTargets.has(member.id)
|
||||
&& !plan.shieldTargets.has(member.id)
|
||||
&& !plan.damageReductionTargets.has(member.id)
|
||||
&& !(member.id === targetId && spell.kind === 'bounce_heal' && profile.bounceHeals)
|
||||
) return member
|
||||
|
||||
if (spell.kind === 'shield') {
|
||||
let nextMember: PartyMember = {
|
||||
...member,
|
||||
shield: Math.max(member.shield, profile.power.shield(spell.power)),
|
||||
}
|
||||
if (plan.hotTargets.has(member.id) || profile.effects.shieldAppliesHot(spell)) {
|
||||
nextMember = applyHot(nextMember, profile.effects.renewSpell ?? spell, profile)
|
||||
}
|
||||
return nextMember
|
||||
}
|
||||
|
||||
if (spell.kind === 'damage_reduction') {
|
||||
return {
|
||||
...member,
|
||||
damageReductionTicks: profile.hot.merge === 'max'
|
||||
? Math.max(member.damageReductionTicks ?? 0, profile.damageReductionTicks)
|
||||
: profile.damageReductionTicks,
|
||||
hotTicks: plan.hotTargets.has(member.id)
|
||||
? (profile.hot.merge === 'max'
|
||||
? Math.max(member.hotTicks, profile.hot.defaultTicks)
|
||||
: profile.hot.defaultTicks)
|
||||
: member.hotTicks,
|
||||
}
|
||||
}
|
||||
|
||||
if (spell.kind === 'bounce_heal' && profile.bounceHeals) {
|
||||
return { ...member, bounceHeals: addBounceHeal(member, spell) }
|
||||
}
|
||||
|
||||
if (spell.kind === 'cleanse') {
|
||||
const nextHealth = profile.heal(member, profile.power.cleanse(spell), profile.healingMultiplier(member))
|
||||
recordFloatingHeal(floatingHeals, profile.floatingHeals.cleanse, member, nextHealth)
|
||||
let nextMember: PartyMember = {
|
||||
...clearNegativeEffects(member),
|
||||
health: nextHealth,
|
||||
shield: plan.shieldTargets.has(member.id)
|
||||
? Math.max(
|
||||
member.shield,
|
||||
profile.power.shield(profile.effects.shieldSpell?.power ?? spell.power),
|
||||
)
|
||||
: member.shield,
|
||||
}
|
||||
if (plan.hotTargets.has(member.id)) {
|
||||
nextMember = applyHot(nextMember, profile.effects.renewSpell ?? spell, profile)
|
||||
}
|
||||
return nextMember
|
||||
}
|
||||
|
||||
const nextHealth = plan.directTargets.has(member.id)
|
||||
? profile.heal(member, profile.power.direct(spell), profile.healingMultiplier(member))
|
||||
: member.health
|
||||
recordFloatingHeal(floatingHeals, profile.floatingHeals.direct, member, nextHealth)
|
||||
let nextMember: PartyMember = {
|
||||
...member,
|
||||
health: nextHealth,
|
||||
shield: plan.shieldTargets.has(member.id)
|
||||
? Math.max(
|
||||
member.shield,
|
||||
profile.power.shield(
|
||||
profile.effects.shieldSpell?.power ?? spell.power,
|
||||
profile.ratios.directShield,
|
||||
),
|
||||
)
|
||||
: member.shield,
|
||||
}
|
||||
if (plan.hotTargets.has(member.id)) {
|
||||
nextMember = applyHot(nextMember, profile.effects.hotSpellForDirect(spell), profile)
|
||||
}
|
||||
return nextMember
|
||||
})
|
||||
|
||||
return { party: nextParty, floatingHeals }
|
||||
}
|
||||
|
||||
export function advanceFreeCastProgress({
|
||||
enabled,
|
||||
wasReady,
|
||||
castsTowardFree,
|
||||
}: {
|
||||
enabled: boolean
|
||||
wasReady: boolean
|
||||
castsTowardFree: number
|
||||
}) {
|
||||
if (!enabled) {
|
||||
return {
|
||||
castsTowardFree,
|
||||
freeCastReady: wasReady,
|
||||
}
|
||||
}
|
||||
if (wasReady) {
|
||||
return {
|
||||
castsTowardFree: 0,
|
||||
freeCastReady: false,
|
||||
}
|
||||
}
|
||||
const nextCasts = castsTowardFree + 1
|
||||
return {
|
||||
castsTowardFree: nextCasts >= 5 ? 0 : nextCasts,
|
||||
freeCastReady: nextCasts >= 5,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { Spell } from '../game'
|
||||
import { stackCount, type StackCounts } from './stackCounts'
|
||||
|
||||
export type SlotModifier<T extends string> = {
|
||||
stacks: StackCounts<T>
|
||||
id: (slot: string) => T
|
||||
multiplier?: number
|
||||
}
|
||||
|
||||
export type StackModifier<T extends string> = {
|
||||
stacks: StackCounts<T>
|
||||
id: T
|
||||
multiplier?: number
|
||||
}
|
||||
|
||||
function slotKey(spell: Spell) {
|
||||
return String(spell.key)
|
||||
}
|
||||
|
||||
function slotModifierStacks<T extends string>(spell: Spell, modifier?: SlotModifier<T>) {
|
||||
return modifier ? stackCount(modifier.stacks, modifier.id(slotKey(spell))) : 0
|
||||
}
|
||||
|
||||
function stackModifierStacks<T extends string>(modifier?: StackModifier<T>) {
|
||||
return modifier ? stackCount(modifier.stacks, modifier.id) : 0
|
||||
}
|
||||
|
||||
function stackMultiplier<T extends string>(modifier?: StackModifier<T>) {
|
||||
if (!modifier) return 1
|
||||
return (modifier.multiplier ?? 1.25) ** stackModifierStacks(modifier)
|
||||
}
|
||||
|
||||
export function spellSlotMultiplier<TPositive extends string, TNegative extends string = never>(
|
||||
spell: Spell,
|
||||
positive?: SlotModifier<TPositive>,
|
||||
negative?: SlotModifier<TNegative>,
|
||||
) {
|
||||
const positiveMultiplier = (positive?.multiplier ?? 0.75) ** slotModifierStacks(spell, positive)
|
||||
const negativeMultiplier = (negative?.multiplier ?? 1.25) ** slotModifierStacks(spell, negative)
|
||||
return positiveMultiplier * negativeMultiplier
|
||||
}
|
||||
|
||||
export function spellCooldownMultiplier<TPositive extends string, TNegative extends string = never>(
|
||||
spell: Spell,
|
||||
cooldownDown?: SlotModifier<TPositive>,
|
||||
cooldownUp?: SlotModifier<TNegative>,
|
||||
) {
|
||||
return spellSlotMultiplier(spell, cooldownDown, cooldownUp)
|
||||
}
|
||||
|
||||
export function spellResourceCost<TPositive extends string, TNegative extends string = never>({
|
||||
spell,
|
||||
costDown,
|
||||
costUp,
|
||||
freeCastReady = false,
|
||||
freeCast,
|
||||
}: {
|
||||
spell: Spell
|
||||
costDown?: SlotModifier<TPositive>
|
||||
costUp?: SlotModifier<TNegative>
|
||||
freeCastReady?: boolean
|
||||
freeCast?: StackModifier<TPositive>
|
||||
}) {
|
||||
if (freeCastReady && stackModifierStacks(freeCast) > 0) return 0
|
||||
return Math.ceil(spell.cost * spellSlotMultiplier(spell, costDown, costUp))
|
||||
}
|
||||
|
||||
export function spellExtraTargets<T extends string>(
|
||||
spell: Spell,
|
||||
extraTarget: SlotModifier<T>,
|
||||
) {
|
||||
return slotModifierStacks(spell, extraTarget)
|
||||
}
|
||||
|
||||
export function spellPowerMultiplier<T extends string>(modifier: StackModifier<T>) {
|
||||
return stackMultiplier(modifier)
|
||||
}
|
||||
|
||||
export function hasSpellStack<T extends string>(modifier: StackModifier<T>) {
|
||||
return stackModifierStacks(modifier) > 0
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
export type StackCounts<T extends string> = ReadonlyMap<T, number>
|
||||
|
||||
export function createStackCounts<T extends string>(items: readonly T[]): StackCounts<T> {
|
||||
const counts = new Map<T, number>()
|
||||
items.forEach((item) => counts.set(item, (counts.get(item) ?? 0) + 1))
|
||||
return counts
|
||||
}
|
||||
|
||||
export function stackCount<T extends string>(counts: StackCounts<T>, id: T) {
|
||||
return counts.get(id) ?? 0
|
||||
}
|
||||
|
||||
export function summarizeStackCounts<T extends string, TChoice extends { id: T; name: string }>(
|
||||
counts: StackCounts<T>,
|
||||
catalog: readonly TChoice[],
|
||||
emptyLabel = '',
|
||||
) {
|
||||
const summary = Array.from(counts.entries())
|
||||
.map(([id, count]) => {
|
||||
const label = catalog.find((choice) => choice.id === id)?.name ?? id
|
||||
return count > 1 ? `${label} x${count}` : label
|
||||
})
|
||||
.join(', ')
|
||||
return summary || emptyLabel
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
export type StadiumRoundOutcome = 'win' | 'loss' | 'tie'
|
||||
export type StadiumWins = {
|
||||
player: number
|
||||
opponent: number
|
||||
}
|
||||
export type StadiumRoundStatus = 'playing' | 'shop' | 'won' | 'lost'
|
||||
export type StadiumExperienceMode =
|
||||
| 'pvp-stadium-round-win-quarter-level'
|
||||
| 'pvp-stadium-round-loss-tenth-level'
|
||||
| 'pvp-stadium-match-half-level'
|
||||
|
||||
export const DEFAULT_STADIUM_WIN_ROUNDS = 3
|
||||
|
||||
export function stadiumShopPointsForOutcome(outcome: StadiumRoundOutcome, side: 'player' | 'opponent') {
|
||||
if (side === 'player') return outcome === 'loss' ? 4 : 3
|
||||
return outcome === 'win' ? 4 : 3
|
||||
}
|
||||
|
||||
export function resolveStadiumRound({
|
||||
outcome,
|
||||
roundIndex,
|
||||
wins,
|
||||
winRounds = DEFAULT_STADIUM_WIN_ROUNDS,
|
||||
}: {
|
||||
outcome: StadiumRoundOutcome
|
||||
roundIndex: number
|
||||
wins: StadiumWins
|
||||
winRounds?: number
|
||||
}) {
|
||||
const roundExperienceMode: StadiumExperienceMode = outcome === 'loss'
|
||||
? 'pvp-stadium-round-loss-tenth-level'
|
||||
: 'pvp-stadium-round-win-quarter-level'
|
||||
const logTone = outcome === 'loss' ? 'danger' as const : 'loot' as const
|
||||
const nextWins = {
|
||||
player: wins.player + (outcome === 'win' ? 1 : 0),
|
||||
opponent: wins.opponent + (outcome === 'loss' ? 1 : 0),
|
||||
}
|
||||
const status: StadiumRoundStatus = nextWins.player >= winRounds
|
||||
? 'won'
|
||||
: nextWins.opponent >= winRounds
|
||||
? 'lost'
|
||||
: 'shop'
|
||||
|
||||
return {
|
||||
nextWins,
|
||||
status,
|
||||
playerRoundStatus: status,
|
||||
roundExperience: {
|
||||
key: `round-${roundIndex}-${outcome}`,
|
||||
mode: roundExperienceMode,
|
||||
},
|
||||
matchExperience: status === 'won'
|
||||
? { key: 'match-win', mode: 'pvp-stadium-match-half-level' as StadiumExperienceMode }
|
||||
: null,
|
||||
log: {
|
||||
text: outcome === 'win'
|
||||
? `Round ${roundIndex} won.`
|
||||
: outcome === 'loss'
|
||||
? `Round ${roundIndex} lost.`
|
||||
: `Round ${roundIndex} tied.`,
|
||||
tone: logTone,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function chooseStadiumCpuPurchases<TId extends string, TBuff extends { id: TId; cost: number }>(
|
||||
catalog: readonly TBuff[],
|
||||
points: number,
|
||||
random = Math.random,
|
||||
) {
|
||||
let remaining = points
|
||||
const purchases: TId[] = []
|
||||
while (remaining > 0) {
|
||||
const affordable = catalog.filter((buff) => buff.cost <= remaining)
|
||||
if (affordable.length === 0) break
|
||||
const selected = affordable[Math.floor(random() * affordable.length)]
|
||||
purchases.push(selected.id)
|
||||
remaining -= selected.cost
|
||||
}
|
||||
return purchases
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import type { PartyMember } from '../game'
|
||||
import type { PvpMatchSnapshot, PvpMatchSide } from '../pvpRoguelike'
|
||||
import { resetParty } from './rules'
|
||||
import type { StadiumRoundOutcome, StadiumWins } from './stadiumLifecycle'
|
||||
|
||||
export type StadiumSetupSide<TBuff extends string> = {
|
||||
party: PartyMember[]
|
||||
resource: number
|
||||
cooldowns: Record<string, number>
|
||||
buffs: TBuff[]
|
||||
castsTowardFree: number
|
||||
freeCastReady: boolean
|
||||
survivalSeconds: number
|
||||
dampeningPercent: number
|
||||
roundIndex: number
|
||||
roundWins: number
|
||||
roundStatus: 'playing' | 'shop' | 'won' | 'lost'
|
||||
lastRoundOutcome?: StadiumRoundOutcome
|
||||
shopReady: boolean
|
||||
}
|
||||
|
||||
export type StadiumLiveMatchSetup = {
|
||||
id: string
|
||||
side: PvpMatchSide
|
||||
opponentSide: PvpMatchSide
|
||||
opponentName: string
|
||||
opponentClassName: string
|
||||
}
|
||||
|
||||
export function createStadiumStarterSide<TBuff extends string>({
|
||||
partyTemplate,
|
||||
maxResource,
|
||||
roundIndex,
|
||||
buffs = [],
|
||||
roundWins = 0,
|
||||
}: {
|
||||
partyTemplate: readonly PartyMember[]
|
||||
maxResource: number
|
||||
roundIndex: number
|
||||
buffs?: TBuff[]
|
||||
roundWins?: number
|
||||
}): StadiumSetupSide<TBuff> {
|
||||
return {
|
||||
party: resetParty([...partyTemplate]),
|
||||
resource: maxResource,
|
||||
cooldowns: {},
|
||||
buffs,
|
||||
castsTowardFree: 0,
|
||||
freeCastReady: false,
|
||||
survivalSeconds: 0,
|
||||
dampeningPercent: 0,
|
||||
roundIndex,
|
||||
roundWins,
|
||||
roundStatus: 'playing',
|
||||
shopReady: false,
|
||||
}
|
||||
}
|
||||
|
||||
export function createStadiumMatchStart<TBuff extends string>({
|
||||
partyTemplate,
|
||||
opponentPartyTemplate,
|
||||
maxResource,
|
||||
}: {
|
||||
partyTemplate: readonly PartyMember[]
|
||||
opponentPartyTemplate: readonly PartyMember[]
|
||||
maxResource: number
|
||||
}) {
|
||||
const roundIndex = 1
|
||||
return {
|
||||
playerSide: createStadiumStarterSide<TBuff>({ partyTemplate, maxResource, roundIndex }),
|
||||
opponentSide: createStadiumStarterSide<TBuff>({ partyTemplate: opponentPartyTemplate, maxResource, roundIndex }),
|
||||
defaults: {
|
||||
roundIndex,
|
||||
roundWins: { player: 0, opponent: 0 } satisfies StadiumWins,
|
||||
elapsedTicks: 0,
|
||||
shopPoints: 0,
|
||||
shopReady: false,
|
||||
paused: false,
|
||||
rewardError: '',
|
||||
showEndLog: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function createStadiumLiveMatchStart<TBuff extends string>({
|
||||
match,
|
||||
side,
|
||||
partyTemplate,
|
||||
opponentPartyTemplate,
|
||||
maxResource,
|
||||
message,
|
||||
}: {
|
||||
match: PvpMatchSnapshot<StadiumSetupSide<TBuff>>
|
||||
side: PvpMatchSide
|
||||
partyTemplate: readonly PartyMember[]
|
||||
opponentPartyTemplate: readonly PartyMember[]
|
||||
maxResource: number
|
||||
message?: string
|
||||
}) {
|
||||
const opponentSideId: PvpMatchSide = side === 'a' ? 'b' : 'a'
|
||||
const opponent = match.players[opponentSideId]
|
||||
const opponentTemplate = opponentPartyTemplate.map((member) => ({
|
||||
...member,
|
||||
name: member.id === 'mira' ? opponent.characterName : member.name,
|
||||
}))
|
||||
const setup = createStadiumMatchStart<TBuff>({
|
||||
partyTemplate,
|
||||
opponentPartyTemplate: opponentTemplate,
|
||||
maxResource,
|
||||
})
|
||||
const liveMatch: StadiumLiveMatchSetup = {
|
||||
id: match.id,
|
||||
side,
|
||||
opponentSide: opponentSideId,
|
||||
opponentName: opponent.characterName,
|
||||
opponentClassName: opponent.className,
|
||||
}
|
||||
|
||||
return {
|
||||
...setup,
|
||||
liveMatch,
|
||||
logText: message ?? `${opponent.characterName} found. Stadium begins.`,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { PartyMember } from '../game'
|
||||
|
||||
export type NavigateAction = 'navigateLeft' | 'navigateRight' | 'navigateUp' | 'navigateDown'
|
||||
|
||||
type TargetOptions = {
|
||||
livingOnly?: boolean
|
||||
}
|
||||
|
||||
function canTarget(member: PartyMember, options: TargetOptions) {
|
||||
return !options.livingOnly || member.health > 0
|
||||
}
|
||||
|
||||
export function selectRelativePartyTarget(
|
||||
party: PartyMember[],
|
||||
selectedId: string,
|
||||
direction: -1 | 1,
|
||||
options: TargetOptions = {},
|
||||
) {
|
||||
const candidates = party.filter((member) => canTarget(member, options))
|
||||
if (candidates.length === 0) return null
|
||||
const currentIndex = candidates.findIndex((member) => member.id === selectedId)
|
||||
const nextIndex = currentIndex < 0
|
||||
? 0
|
||||
: (currentIndex + direction + candidates.length) % candidates.length
|
||||
return candidates[nextIndex]?.id ?? null
|
||||
}
|
||||
|
||||
export function selectDirectionalPartyTarget(
|
||||
party: PartyMember[],
|
||||
selectedId: string,
|
||||
action: NavigateAction,
|
||||
columns: number,
|
||||
options: TargetOptions = {},
|
||||
) {
|
||||
const currentIndex = party.findIndex((member) => member.id === selectedId)
|
||||
if (currentIndex < 0) {
|
||||
return party.find((member) => canTarget(member, options))?.id ?? null
|
||||
}
|
||||
const currentRow = Math.floor(currentIndex / columns)
|
||||
const currentColumn = currentIndex % columns
|
||||
const horizontal = action === 'navigateLeft' || action === 'navigateRight'
|
||||
const candidate = party
|
||||
.map((member, index) => ({
|
||||
member,
|
||||
index,
|
||||
row: Math.floor(index / columns),
|
||||
column: index % columns,
|
||||
}))
|
||||
.filter(({ member, index, row, column }) => {
|
||||
if (!canTarget(member, options) || index === currentIndex) return false
|
||||
if (action === 'navigateLeft') return row === currentRow && column < currentColumn
|
||||
if (action === 'navigateRight') return row === currentRow && column > currentColumn
|
||||
if (action === 'navigateUp') return row < currentRow
|
||||
return row > currentRow
|
||||
})
|
||||
.sort((left, right) => {
|
||||
const leftPrimary = horizontal
|
||||
? Math.abs(left.column - currentColumn)
|
||||
: Math.abs(left.row - currentRow)
|
||||
const rightPrimary = horizontal
|
||||
? Math.abs(right.column - currentColumn)
|
||||
: Math.abs(right.row - currentRow)
|
||||
const leftSecondary = horizontal ? 0 : Math.abs(left.column - currentColumn)
|
||||
const rightSecondary = horizontal ? 0 : Math.abs(right.column - currentColumn)
|
||||
return leftPrimary - rightPrimary || leftSecondary - rightSecondary
|
||||
})[0]
|
||||
return candidate?.member.id ?? null
|
||||
}
|
||||
|
||||
export function selectDirectPartyTarget(
|
||||
party: PartyMember[],
|
||||
slot: number,
|
||||
options: TargetOptions & {
|
||||
targetGroup?: number
|
||||
groupSize?: number
|
||||
} = {},
|
||||
) {
|
||||
const groupSize = options.groupSize ?? 6
|
||||
const index = slot + (options.targetGroup ?? 0) * groupSize
|
||||
const member = party[index]
|
||||
return member && canTarget(member, options) ? member.id : null
|
||||
}
|
||||
|
||||
export function nextTargetGroupSelection(
|
||||
party: PartyMember[],
|
||||
selectedId: string,
|
||||
currentGroup: number,
|
||||
groupSize = 6,
|
||||
) {
|
||||
const groupCount = Math.max(1, Math.ceil(party.length / groupSize))
|
||||
const nextGroup = (currentGroup + 1) % groupCount
|
||||
const selectedIndex = party.findIndex((member) => member.id === selectedId)
|
||||
const slot = selectedIndex < 0 ? 0 : selectedIndex % groupSize
|
||||
return {
|
||||
group: nextGroup,
|
||||
selectedId: party[slot + nextGroup * groupSize]?.id ?? null,
|
||||
}
|
||||
}
|
||||
+404
-734
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
import type { CSSProperties } from 'react'
|
||||
import { memo, type CSSProperties } from 'react'
|
||||
import {
|
||||
bindingLabel,
|
||||
compactBindingLabel,
|
||||
@@ -31,7 +31,7 @@ function faceButtonFor(binding: string, iconStyle: ControllerIconStyle) {
|
||||
return FACE_BUTTONS[iconStyle][Number(binding.slice(6))] ?? null
|
||||
}
|
||||
|
||||
function FaceIcon({
|
||||
const FaceIcon = memo(function FaceIcon({
|
||||
color,
|
||||
iconStyle,
|
||||
label,
|
||||
@@ -52,9 +52,9 @@ function FaceIcon({
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
export function ControllerBindingLabel({
|
||||
export const ControllerBindingLabel = memo(function ControllerBindingLabel({
|
||||
binding,
|
||||
compact = false,
|
||||
iconStyle,
|
||||
@@ -78,9 +78,9 @@ export function ControllerBindingLabel({
|
||||
}
|
||||
|
||||
return <>{compact ? compactBindingLabel(binding, iconStyle) : title}</>
|
||||
}
|
||||
})
|
||||
|
||||
export function ControllerStylePreview({ iconStyle }: { iconStyle: ControllerIconStyle }) {
|
||||
export const ControllerStylePreview = memo(function ControllerStylePreview({ iconStyle }: { iconStyle: ControllerIconStyle }) {
|
||||
return (
|
||||
<span className="controller-style-preview" aria-hidden="true">
|
||||
{[0, 1, 2, 3].map((button) => {
|
||||
@@ -99,4 +99,4 @@ export function ControllerStylePreview({ iconStyle }: { iconStyle: ControllerIco
|
||||
})}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -84,6 +84,7 @@ export function EquipmentScreen({
|
||||
const [upgrading, setUpgrading] = useState(false)
|
||||
const [showSetBonuses, setShowSetBonuses] = useState(false)
|
||||
const [equipmentTab, setEquipmentTab] = useState<'equipment' | 'crafting'>(mode ?? 'equipment')
|
||||
const activeEquipmentTab = mode ?? equipmentTab
|
||||
const [inventoryPage, setInventoryPage] = useState(0)
|
||||
const [recipePage, setRecipePage] = useState(0)
|
||||
const [message, setMessage] = useState('')
|
||||
@@ -97,10 +98,6 @@ export function EquipmentScreen({
|
||||
const [selectedRecipeId, setSelectedRecipeId] = useState<number | null>(
|
||||
firstRecipe?.id ?? null,
|
||||
)
|
||||
const selectedRecipe = profile.craftingRecipes.find((recipe) => recipe.id === selectedRecipeId)
|
||||
const selectedRecipeRequiresUpgrade = selectedRecipe
|
||||
? !DIRECT_CRAFT_ITEM_LEVELS.has(selectedRecipe.item.itemLevel)
|
||||
: false
|
||||
const selectedItemRecipe = selectedItem
|
||||
? profile.craftingRecipes.find((recipe) => recipe.item.id === selectedItem.id)
|
||||
: undefined
|
||||
@@ -132,9 +129,10 @@ export function EquipmentScreen({
|
||||
1,
|
||||
Math.ceil(visibleInventory.length / EQUIPMENT_LIST_PAGE_SIZE),
|
||||
)
|
||||
const activeInventoryPage = Math.min(inventoryPage, inventoryPageCount - 1)
|
||||
const inventoryPageItems = visibleInventory.slice(
|
||||
inventoryPage * EQUIPMENT_LIST_PAGE_SIZE,
|
||||
(inventoryPage + 1) * EQUIPMENT_LIST_PAGE_SIZE,
|
||||
activeInventoryPage * EQUIPMENT_LIST_PAGE_SIZE,
|
||||
(activeInventoryPage + 1) * EQUIPMENT_LIST_PAGE_SIZE,
|
||||
)
|
||||
|
||||
const [slotFilter, setSlotFilter] = useState<EquipmentSlot | 'all'>('all')
|
||||
@@ -172,42 +170,28 @@ export function EquipmentScreen({
|
||||
1,
|
||||
Math.ceil(filteredRecipes.length / CRAFTING_LIST_PAGE_SIZE),
|
||||
)
|
||||
const activeRecipePage = Math.min(recipePage, recipePageCount - 1)
|
||||
const recipePageItems = filteredRecipes.slice(
|
||||
recipePage * CRAFTING_LIST_PAGE_SIZE,
|
||||
(recipePage + 1) * CRAFTING_LIST_PAGE_SIZE,
|
||||
activeRecipePage * CRAFTING_LIST_PAGE_SIZE,
|
||||
(activeRecipePage + 1) * CRAFTING_LIST_PAGE_SIZE,
|
||||
)
|
||||
const activeSelectedRecipeId = filteredRecipes.some((recipe) => recipe.id === selectedRecipeId)
|
||||
? selectedRecipeId
|
||||
: filteredRecipes[0]?.id ?? null
|
||||
const selectedRecipe = profile.craftingRecipes.find((recipe) => recipe.id === activeSelectedRecipeId)
|
||||
const selectedRecipeRequiresUpgrade = selectedRecipe
|
||||
? !DIRECT_CRAFT_ITEM_LEVELS.has(selectedRecipe.item.itemLevel)
|
||||
: false
|
||||
|
||||
useEffect(() => {
|
||||
window.scrollTo(0, scrollRef.current)
|
||||
}, [profile])
|
||||
|
||||
useEffect(() => {
|
||||
setInventoryPage((current) => Math.min(current, inventoryPageCount - 1))
|
||||
}, [inventoryPageCount])
|
||||
|
||||
useEffect(() => {
|
||||
setRecipePage((current) => Math.min(current, recipePageCount - 1))
|
||||
}, [recipePageCount])
|
||||
|
||||
useEffect(() => {
|
||||
if (filteredRecipes.length === 0) {
|
||||
setSelectedRecipeId(null)
|
||||
return
|
||||
}
|
||||
if (!filteredRecipes.some((recipe) => recipe.id === selectedRecipeId)) {
|
||||
setSelectedRecipeId(filteredRecipes[0].id)
|
||||
}
|
||||
}, [filteredRecipes, selectedRecipeId])
|
||||
|
||||
useEffect(() => {
|
||||
if (equipmentTab === 'crafting') {
|
||||
if (activeEquipmentTab === 'crafting') {
|
||||
loadProfile().then((fresh) => onUpdated(fresh)).catch(() => {})
|
||||
}
|
||||
}, [equipmentTab])
|
||||
|
||||
useEffect(() => {
|
||||
if (mode) setEquipmentTab(mode)
|
||||
}, [mode])
|
||||
}, [activeEquipmentTab, onUpdated])
|
||||
|
||||
function saveScroll() {
|
||||
scrollRef.current = window.scrollY
|
||||
@@ -330,7 +314,7 @@ export function EquipmentScreen({
|
||||
}
|
||||
|
||||
const workshopState = useMemo<DualScreenWorkshopState>(() => {
|
||||
if (equipmentTab === 'crafting') {
|
||||
if (activeEquipmentTab === 'crafting') {
|
||||
if (!selectedRecipe) {
|
||||
return {
|
||||
mode: 'crafting',
|
||||
@@ -416,7 +400,7 @@ export function EquipmentScreen({
|
||||
: []),
|
||||
],
|
||||
}
|
||||
}, [comparisonItem, equipmentTab, selectedItem, selectedRecipe, upgradeRecipe])
|
||||
}, [activeEquipmentTab, comparisonItem, selectedItem, selectedRecipe, upgradeRecipe])
|
||||
|
||||
useDualScreenWorkshopPublisher(workshopState, dualScreenEnabled)
|
||||
|
||||
@@ -449,14 +433,14 @@ export function EquipmentScreen({
|
||||
{showModeTabs && (
|
||||
<nav className="equipment-tabs">
|
||||
<button
|
||||
className={`equipment-tab ${equipmentTab === 'equipment' ? 'active' : ''}`}
|
||||
className={`equipment-tab ${activeEquipmentTab === 'equipment' ? 'active' : ''}`}
|
||||
onClick={() => setEquipmentTab('equipment')}
|
||||
type="button"
|
||||
>
|
||||
Equipment
|
||||
</button>
|
||||
<button
|
||||
className={`equipment-tab ${equipmentTab === 'crafting' ? 'active' : ''}`}
|
||||
className={`equipment-tab ${activeEquipmentTab === 'crafting' ? 'active' : ''}`}
|
||||
onClick={() => setEquipmentTab('crafting')}
|
||||
type="button"
|
||||
>
|
||||
@@ -465,7 +449,7 @@ export function EquipmentScreen({
|
||||
</nav>
|
||||
)}
|
||||
|
||||
{equipmentTab === 'equipment' ? (
|
||||
{activeEquipmentTab === 'equipment' ? (
|
||||
<>
|
||||
<section className="item-comparison">
|
||||
{selectedItem ? (
|
||||
@@ -577,11 +561,11 @@ export function EquipmentScreen({
|
||||
</div>
|
||||
{visibleInventory.length > EQUIPMENT_LIST_PAGE_SIZE && (
|
||||
<ListPager
|
||||
label={`Page ${inventoryPage + 1} / ${inventoryPageCount}`}
|
||||
onNext={() => setInventoryPage((current) => Math.min(inventoryPageCount - 1, current + 1))}
|
||||
onPrevious={() => setInventoryPage((current) => Math.max(0, current - 1))}
|
||||
nextDisabled={inventoryPage >= inventoryPageCount - 1}
|
||||
previousDisabled={inventoryPage <= 0}
|
||||
label={`Page ${activeInventoryPage + 1} / ${inventoryPageCount}`}
|
||||
onNext={() => setInventoryPage(Math.min(inventoryPageCount - 1, activeInventoryPage + 1))}
|
||||
onPrevious={() => setInventoryPage(Math.max(0, activeInventoryPage - 1))}
|
||||
nextDisabled={activeInventoryPage >= inventoryPageCount - 1}
|
||||
previousDisabled={activeInventoryPage <= 0}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
@@ -666,7 +650,7 @@ export function EquipmentScreen({
|
||||
<EquipmentHeading
|
||||
eyebrow="Available Gear"
|
||||
title={slotFilter === 'all' ? 'Craftable Gear' : SLOT_LABELS[slotFilter]}
|
||||
detail={`Page ${recipePage + 1}/${recipePageCount}`}
|
||||
detail={`Page ${activeRecipePage + 1}/${recipePageCount}`}
|
||||
/>
|
||||
{filteredRecipes.length === 0 ? (
|
||||
<p className="inventory-empty">No recipes match filters.</p>
|
||||
@@ -674,7 +658,7 @@ export function EquipmentScreen({
|
||||
<div className="crafting-list">
|
||||
{recipePageItems.map((recipe) => (
|
||||
<button
|
||||
className={`${selectedRecipeId === recipe.id ? 'selected' : ''} rarity-${recipe.item.rarity}`}
|
||||
className={`${activeSelectedRecipeId === recipe.id ? 'selected' : ''} rarity-${recipe.item.rarity}`}
|
||||
key={recipe.id}
|
||||
onClick={() => setSelectedRecipeId(recipe.id)}
|
||||
type="button"
|
||||
@@ -696,11 +680,11 @@ export function EquipmentScreen({
|
||||
)}
|
||||
{filteredRecipes.length > CRAFTING_LIST_PAGE_SIZE && (
|
||||
<ListPager
|
||||
label={`Page ${recipePage + 1} / ${recipePageCount}`}
|
||||
onNext={() => setRecipePage((current) => Math.min(recipePageCount - 1, current + 1))}
|
||||
onPrevious={() => setRecipePage((current) => Math.max(0, current - 1))}
|
||||
nextDisabled={recipePage >= recipePageCount - 1}
|
||||
previousDisabled={recipePage <= 0}
|
||||
label={`Page ${activeRecipePage + 1} / ${recipePageCount}`}
|
||||
onNext={() => setRecipePage(Math.min(recipePageCount - 1, activeRecipePage + 1))}
|
||||
onPrevious={() => setRecipePage(Math.max(0, activeRecipePage - 1))}
|
||||
nextDisabled={activeRecipePage >= recipePageCount - 1}
|
||||
previousDisabled={activeRecipePage <= 0}
|
||||
/>
|
||||
)}
|
||||
<div className="crafting-action-row">
|
||||
@@ -748,7 +732,7 @@ export function EquipmentScreen({
|
||||
</section>
|
||||
)}
|
||||
|
||||
{equipmentTab === 'equipment' && profile.setBonuses.length > 0 && (
|
||||
{activeEquipmentTab === 'equipment' && profile.setBonuses.length > 0 && (
|
||||
<section className="set-bonus-panel">
|
||||
<div className="equipment-heading toggle-heading">
|
||||
<div>
|
||||
@@ -784,11 +768,11 @@ export function EquipmentScreen({
|
||||
)
|
||||
|
||||
if (embedded) {
|
||||
return <div className={`equipment-screen embedded-screen ${equipmentTab === 'crafting' ? 'crafting-active' : ''}`}>{content}</div>
|
||||
return <div className={`equipment-screen embedded-screen ${activeEquipmentTab === 'crafting' ? 'crafting-active' : ''}`}>{content}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={`content-screen equipment-screen ${equipmentTab === 'crafting' ? 'crafting-active' : ''}`}>
|
||||
<section className={`content-screen equipment-screen ${activeEquipmentTab === 'crafting' ? 'crafting-active' : ''}`}>
|
||||
{content}
|
||||
</section>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import { memo, type ElementType, type ReactNode } from 'react'
|
||||
import type { ControllerIconStyle } from '../input'
|
||||
import type { PartyMember } from '../game'
|
||||
import {
|
||||
effectiveMaxHealth,
|
||||
formatEffectTime,
|
||||
memberHotEffects,
|
||||
} from '../combat/rules'
|
||||
import { ControllerBindingLabel } from './ControllerIcons'
|
||||
|
||||
export type FloatingCombatText = {
|
||||
id: number
|
||||
memberId: string
|
||||
value: number
|
||||
}
|
||||
|
||||
type MemberEffectMode = 'full' | 'compact' | 'timed-basic'
|
||||
|
||||
const MemberEffects = memo(function MemberEffects({
|
||||
member,
|
||||
mode,
|
||||
}: {
|
||||
member: PartyMember
|
||||
mode: MemberEffectMode
|
||||
}) {
|
||||
if (mode === 'compact') {
|
||||
return (
|
||||
<div className="member-effects">
|
||||
{memberHotEffects(member).map((effect) => (
|
||||
<span className="buff" key={effect.id}>{effect.label}</span>
|
||||
))}
|
||||
{member.shield > 0 && <span className="buff">Shield {Math.ceil(member.shield)}</span>}
|
||||
{member.debuff && <span className="debuff">{member.debuff}</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (mode === 'timed-basic') {
|
||||
return (
|
||||
<div className="member-effects">
|
||||
{member.hotTicks > 0 && <span className="buff">Renew {formatEffectTime(member.hotTicks)}</span>}
|
||||
{member.shield > 0 && <span className="buff">Shield {Math.ceil(member.shield)}</span>}
|
||||
{member.debuff && member.debuffTicks && <span className="debuff">{member.debuff} {formatEffectTime(member.debuffTicks)}</span>}
|
||||
{(member.poisonStacks ?? 0) > 0 && <span className="debuff">Poison {member.poisonStacks}</span>}
|
||||
{(member.maxHealthPenaltyTicks ?? 0) > 0 && <span className="debuff">Max HP -25% {formatEffectTime(member.maxHealthPenaltyTicks ?? 0)}</span>}
|
||||
{(member.healingReductionTicks ?? 0) > 0 && <span className="debuff">Healing -25% {formatEffectTime(member.healingReductionTicks ?? 0)}</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="member-effects">
|
||||
{memberHotEffects(member).map((effect) => (
|
||||
<span className="buff" key={effect.id}>{effect.label} {formatEffectTime(effect.ticks)}</span>
|
||||
))}
|
||||
{member.shield > 0 && <span className="buff">Shield {Math.ceil(member.shield)}</span>}
|
||||
{(member.damageReductionTicks ?? 0) > 0 && <span className="buff">Barkskin {formatEffectTime(member.damageReductionTicks ?? 0)}</span>}
|
||||
{(member.bounceHeals ?? []).map((effect) => (
|
||||
<span className="buff" key={effect.id}>{effect.label} {effect.charges}</span>
|
||||
))}
|
||||
{member.debuff && member.debuffTicks && <span className="debuff">{member.debuff} {formatEffectTime(member.debuffTicks)}</span>}
|
||||
{(member.poisonStacks ?? 0) > 0 && <span className="debuff">Poison {member.poisonStacks}</span>}
|
||||
{(member.maxHealthPenaltyTicks ?? 0) > 0 && <span className="debuff">Max HP -25% {formatEffectTime(member.maxHealthPenaltyTicks ?? 0)}</span>}
|
||||
{(member.healingReductionTicks ?? 0) > 0 && <span className="debuff">Healing -25% {formatEffectTime(member.healingReductionTicks ?? 0)}</span>}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
export const PartyMemberFrame = memo(function PartyMemberFrame({
|
||||
as,
|
||||
member,
|
||||
selected = false,
|
||||
baseClassName = 'party-member',
|
||||
selectedClassName = 'selected',
|
||||
deadClassName = 'dead',
|
||||
effectMode = 'full',
|
||||
floatingTexts = [],
|
||||
showTargetMarker = false,
|
||||
showHeaderHealth = true,
|
||||
showHealthText = false,
|
||||
targetBinding,
|
||||
controllerIconStyle,
|
||||
onSelect,
|
||||
children,
|
||||
}: {
|
||||
as?: ElementType
|
||||
member: PartyMember
|
||||
selected?: boolean
|
||||
baseClassName?: string
|
||||
selectedClassName?: string
|
||||
deadClassName?: string
|
||||
effectMode?: MemberEffectMode
|
||||
floatingTexts?: FloatingCombatText[]
|
||||
showTargetMarker?: boolean
|
||||
showHeaderHealth?: boolean
|
||||
showHealthText?: boolean
|
||||
targetBinding?: string | null
|
||||
controllerIconStyle?: ControllerIconStyle
|
||||
onSelect?: (id: string) => void
|
||||
children?: ReactNode
|
||||
}) {
|
||||
const Component = as ?? (onSelect ? 'button' : 'div')
|
||||
const maxHealth = effectiveMaxHealth(member)
|
||||
const className = [
|
||||
baseClassName,
|
||||
selected ? selectedClassName : '',
|
||||
member.health <= 0 ? deadClassName : '',
|
||||
].filter(Boolean).join(' ')
|
||||
const action = targetBinding ? {
|
||||
binding: targetBinding,
|
||||
iconStyle: controllerIconStyle ?? 'xbox',
|
||||
} : null
|
||||
|
||||
return (
|
||||
<Component
|
||||
className={className}
|
||||
data-party-member-id={member.id}
|
||||
onClick={onSelect ? () => onSelect(member.id) : undefined}
|
||||
aria-pressed={onSelect ? selected : undefined}
|
||||
type={Component === 'button' ? 'button' : undefined}
|
||||
>
|
||||
{showTargetMarker && (
|
||||
<span className="target-marker" aria-hidden="true">
|
||||
<i />
|
||||
Target
|
||||
</span>
|
||||
)}
|
||||
<div className="member-header">
|
||||
<span className={`role role-${member.role.toLowerCase()}`}>{member.role[0]}</span>
|
||||
<strong>{member.name}</strong>
|
||||
{showHeaderHealth && <small>{Math.ceil(member.health)} / {maxHealth}</small>}
|
||||
</div>
|
||||
<div className="bar member-health">
|
||||
<span style={{ width: `${(member.health / maxHealth) * 100}%` }} />
|
||||
{member.shield > 0 && <i style={{ width: `${(member.shield / maxHealth) * 100}%` }} />}
|
||||
{showHealthText && <em className="health-text">{Math.floor(member.health)} / {maxHealth}</em>}
|
||||
</div>
|
||||
<div className="floating-combat-texts" aria-hidden="true">
|
||||
{floatingTexts.map((entry) => <span className="floating-heal" key={entry.id}>+{entry.value}</span>)}
|
||||
</div>
|
||||
{action && (
|
||||
<div className="member-target-key">
|
||||
<ControllerBindingLabel
|
||||
binding={action.binding}
|
||||
iconStyle={action.iconStyle}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<MemberEffects member={member} mode={effectMode} />
|
||||
{children}
|
||||
</Component>
|
||||
)
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,68 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { CombatLogEntry } from '../game'
|
||||
import { RematchControls, ResultLogToggle } from './RewardPanels'
|
||||
|
||||
type ResultAction = {
|
||||
label: string
|
||||
onClick: () => void
|
||||
className?: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
type ResultRematch = {
|
||||
visible: boolean
|
||||
requested: boolean
|
||||
message: string
|
||||
onRematch: () => void
|
||||
}
|
||||
|
||||
export function ResultScreen({
|
||||
eyebrow,
|
||||
title,
|
||||
children,
|
||||
log,
|
||||
showLog,
|
||||
onToggleLog,
|
||||
rematch,
|
||||
actions,
|
||||
}: {
|
||||
eyebrow: string
|
||||
title: string
|
||||
children?: ReactNode
|
||||
log?: CombatLogEntry[]
|
||||
showLog?: boolean
|
||||
onToggleLog?: () => void
|
||||
rematch?: ResultRematch
|
||||
actions?: ResultAction[]
|
||||
}) {
|
||||
return (
|
||||
<div className="result-screen">
|
||||
<div>
|
||||
<p className="eyebrow">{eyebrow}</p>
|
||||
<h2>{title}</h2>
|
||||
{children}
|
||||
{log && onToggleLog && (
|
||||
<ResultLogToggle log={log} showEndLog={Boolean(showLog)} onToggle={onToggleLog} />
|
||||
)}
|
||||
{rematch?.visible && (
|
||||
<RematchControls
|
||||
requested={rematch.requested}
|
||||
message={rematch.message}
|
||||
onRematch={rematch.onRematch}
|
||||
/>
|
||||
)}
|
||||
{actions?.map((action) => (
|
||||
<button
|
||||
className={action.className}
|
||||
disabled={action.disabled}
|
||||
key={action.label}
|
||||
onClick={action.onClick}
|
||||
type="button"
|
||||
>
|
||||
{action.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import type { CombatLogEntry } from '../game'
|
||||
import type { DungeonReward, LootRoll } from '../profile'
|
||||
|
||||
type BonusItem = NonNullable<DungeonReward['bonusItem']>
|
||||
|
||||
export function LevelGain({
|
||||
previousLevel,
|
||||
newLevel,
|
||||
talentPointsGained,
|
||||
}: {
|
||||
previousLevel: number | null
|
||||
newLevel: number | null
|
||||
talentPointsGained: number
|
||||
}) {
|
||||
if (!previousLevel || !newLevel || talentPointsGained <= 0) return null
|
||||
return (
|
||||
<p className="level-gain">
|
||||
Level {previousLevel} to {newLevel}
|
||||
<small>+{talentPointsGained} talent point</small>
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
export function AbilityUnlocks({
|
||||
abilities,
|
||||
}: {
|
||||
abilities: DungeonReward['unlockedAbilities']
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{abilities.map((ability) => (
|
||||
<p className="ability-unlock" key={ability.id}>
|
||||
<span>{ability.glyph}</span>
|
||||
Ability Unlocked: {ability.name}
|
||||
</p>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function RewardXpSummary({
|
||||
reward,
|
||||
}: {
|
||||
reward: {
|
||||
experienceGained: number
|
||||
previousLevel: number | null
|
||||
newLevel: number | null
|
||||
talentPointsGained: number
|
||||
unlockedAbilities: DungeonReward['unlockedAbilities']
|
||||
}
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<p>+{reward.experienceGained} XP</p>
|
||||
<LevelGain previousLevel={reward.previousLevel} newLevel={reward.newLevel} talentPointsGained={reward.talentPointsGained} />
|
||||
<AbilityUnlocks abilities={reward.unlockedAbilities} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function BonusItemReward({
|
||||
item,
|
||||
eyebrow,
|
||||
compact = false,
|
||||
}: {
|
||||
item?: BonusItem | null
|
||||
eyebrow?: string
|
||||
compact?: boolean
|
||||
}) {
|
||||
if (!item) return null
|
||||
if (compact) {
|
||||
return (
|
||||
<p className="ability-unlock">
|
||||
<span>{item.glyph}</span>
|
||||
{item.name} x{item.quantity}
|
||||
{item.duplicate ? ` (owned x${item.quantityAfter})` : ''}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="bonus-item">
|
||||
{eyebrow && <p className="eyebrow">{eyebrow}</p>}
|
||||
<div className="bonus-item-detail">
|
||||
<span>{item.glyph}</span>
|
||||
<strong className={`rarity-${item.rarity}`}>{item.name}</strong>
|
||||
<small>Item Level {item.itemLevel} x{item.quantity}</small>
|
||||
{item.duplicate && <small> (owned x{item.quantityAfter})</small>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function LootRollList({
|
||||
rolls,
|
||||
expectedRolls,
|
||||
}: {
|
||||
rolls: LootRoll[]
|
||||
expectedRolls: number
|
||||
}) {
|
||||
return (
|
||||
<div className="run-loot-rolls">
|
||||
{rolls.map((roll) => (
|
||||
<div className={roll.dropped ? 'dropped' : 'empty'} key={roll.encounterId}>
|
||||
<strong>{roll.encounterName}</strong>
|
||||
<span>
|
||||
{roll.items.length > 0
|
||||
? roll.items
|
||||
.map((item) => `${item.glyph} ${item.name} x${item.quantity}${item.duplicate ? ` (owned x${item.quantityAfter})` : ''}`)
|
||||
.join(', ')
|
||||
: 'No components dropped'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{rolls.length < expectedRolls && <small>Finishing loot rolls...</small>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function PvpRunLootList({
|
||||
loot,
|
||||
}: {
|
||||
loot: BonusItem[]
|
||||
}) {
|
||||
return (
|
||||
<div className="run-loot-rolls">
|
||||
{loot.length > 0 ? loot.map((item, index) => (
|
||||
<div className="dropped" key={`${item.id}-${index}`}>
|
||||
<strong>Boss {index + 1}</strong>
|
||||
<span>
|
||||
{item.glyph} {item.name} x{item.quantity}
|
||||
{item.duplicate ? ` (owned x${item.quantityAfter})` : ''}
|
||||
</span>
|
||||
</div>
|
||||
)) : (
|
||||
<div>
|
||||
<strong>Loot</strong>
|
||||
<span>No boss loot awarded</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ResultLogToggle({
|
||||
log,
|
||||
showEndLog,
|
||||
onToggle,
|
||||
}: {
|
||||
log: CombatLogEntry[]
|
||||
showEndLog: boolean
|
||||
onToggle: () => void
|
||||
}) {
|
||||
if (log.length === 0) return null
|
||||
return (
|
||||
<>
|
||||
<button className="secondary-result-button" onClick={onToggle} type="button">
|
||||
{showEndLog ? 'Hide Combat Log' : 'View Combat Log'}
|
||||
</button>
|
||||
{showEndLog && (
|
||||
<div className="result-log">
|
||||
{log.slice().reverse().map((entry) => (
|
||||
<div className={`log-entry ${entry.tone}`} key={entry.id}>{entry.text}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function RematchControls({
|
||||
requested,
|
||||
message,
|
||||
onRematch,
|
||||
}: {
|
||||
requested: boolean
|
||||
message: string
|
||||
onRematch: () => void
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<button disabled={requested} onClick={onRematch} type="button">
|
||||
{requested ? 'Waiting for Rematch' : 'Rematch'}
|
||||
</button>
|
||||
{message && <p>{message}</p>}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { memo } from 'react'
|
||||
import type { ControllerIconStyle } from '../input'
|
||||
import type { Spell } from '../game'
|
||||
import { ControllerBindingLabel } from './ControllerIcons'
|
||||
|
||||
export type SpellSlot = (Spell & {
|
||||
cost: number
|
||||
remaining: number
|
||||
slotIndex: number
|
||||
}) | null
|
||||
|
||||
export const ResourceBar = memo(function ResourceBar({
|
||||
resource,
|
||||
maxResource,
|
||||
resourceName,
|
||||
speedMultiplier,
|
||||
unavailableText,
|
||||
}: {
|
||||
resource: number
|
||||
maxResource: number
|
||||
resourceName: string
|
||||
speedMultiplier?: 1 | 2
|
||||
unavailableText?: string
|
||||
}) {
|
||||
return (
|
||||
<div className="mana-wrap party-mana-wrap">
|
||||
<span>
|
||||
{unavailableText ?? `${resourceName} ${Math.floor(resource)} / ${maxResource}`}
|
||||
</span>
|
||||
{speedMultiplier === 2 && <strong className="speed-badge">2x speed</strong>}
|
||||
<div className="bar mana-bar"><span style={{ width: `${(resource / maxResource) * 100}%` }} /></div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
export const SpellButton = memo(function SpellButton({
|
||||
spell,
|
||||
binding,
|
||||
iconStyle,
|
||||
resourceName,
|
||||
disabled,
|
||||
onCast,
|
||||
emptyKeyPrefix = 'empty',
|
||||
}: {
|
||||
spell: SpellSlot
|
||||
binding?: string
|
||||
iconStyle: ControllerIconStyle
|
||||
resourceName: string
|
||||
disabled?: boolean
|
||||
onCast: (spell: Spell) => void
|
||||
emptyKeyPrefix?: string
|
||||
}) {
|
||||
if (!spell) {
|
||||
return (
|
||||
<div className="spell empty-spell" key={emptyKeyPrefix}>
|
||||
<kbd>{emptyKeyPrefix}</kbd><strong>Empty</strong>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<button
|
||||
className="spell"
|
||||
disabled={disabled}
|
||||
key={spell.id}
|
||||
onClick={() => onCast(spell)}
|
||||
title={spell.description}
|
||||
type="button"
|
||||
>
|
||||
<kbd>
|
||||
{binding
|
||||
? (
|
||||
<ControllerBindingLabel
|
||||
binding={binding}
|
||||
compact
|
||||
iconStyle={iconStyle}
|
||||
/>
|
||||
)
|
||||
: spell.slotIndex + 1}
|
||||
</kbd>
|
||||
<span className={`spell-icon spell-${spell.kind}`}>{spell.glyph}</span>
|
||||
<strong>{spell.name}</strong>
|
||||
<small>{spell.cost} {resourceName}</small>
|
||||
{spell.remaining > 0 && <i>{spell.remaining.toFixed(1)}</i>}
|
||||
</button>
|
||||
)
|
||||
})
|
||||
|
||||
export const SpellBar = memo(function SpellBar({
|
||||
spells,
|
||||
bindings,
|
||||
iconStyle,
|
||||
resource,
|
||||
resourceName,
|
||||
canCast,
|
||||
onCast,
|
||||
className = 'spell-bar six-slots vertical-spell-bar',
|
||||
}: {
|
||||
spells: SpellSlot[]
|
||||
bindings: Record<string, string>
|
||||
iconStyle: ControllerIconStyle
|
||||
resource: number
|
||||
resourceName: string
|
||||
canCast: boolean
|
||||
onCast: (spell: Spell) => void
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<div className={className}>
|
||||
{spells.map((spell, slotIndex) => {
|
||||
if (!spell) {
|
||||
return (
|
||||
<div className="spell empty-spell" key={`empty-${slotIndex}`}>
|
||||
<kbd>{slotIndex + 1}</kbd><strong>Empty</strong>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<SpellButton
|
||||
binding={bindings[`ability${slotIndex + 1}`]}
|
||||
disabled={!canCast || resource < spell.cost || spell.remaining > 0}
|
||||
iconStyle={iconStyle}
|
||||
key={spell.id}
|
||||
onCast={onCast}
|
||||
resourceName={resourceName}
|
||||
spell={spell}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
allocateTalent,
|
||||
resetTalents,
|
||||
@@ -57,24 +57,16 @@ export function TalentScreen({ profile, onBack, onUpdated, embedded = false }: P
|
||||
?? gameClass.talents[0]
|
||||
?? null
|
||||
const effectPageCount = Math.max(1, Math.ceil(gameClass.talents.length / EFFECTS_PER_PAGE))
|
||||
const activeEffectPage = Math.min(effectPage, effectPageCount - 1)
|
||||
const visibleTalents = gameClass.talents.slice(
|
||||
effectPage * EFFECTS_PER_PAGE,
|
||||
effectPage * EFFECTS_PER_PAGE + EFFECTS_PER_PAGE,
|
||||
activeEffectPage * EFFECTS_PER_PAGE,
|
||||
activeEffectPage * EFFECTS_PER_PAGE + EFFECTS_PER_PAGE,
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
window.scrollTo(0, scrollRef.current)
|
||||
}, [profile])
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedTalentId && gameClass.talents.some((talent) => talent.id === selectedTalentId)) return
|
||||
setSelectedTalentId(selectedTalent?.id ?? null)
|
||||
}, [gameClass.talents, selectedTalent?.id, selectedTalentId])
|
||||
|
||||
useEffect(() => {
|
||||
setEffectPage((page) => Math.min(page, effectPageCount - 1))
|
||||
}, [effectPageCount])
|
||||
|
||||
function saveScroll() {
|
||||
scrollRef.current = window.scrollY
|
||||
}
|
||||
@@ -123,9 +115,8 @@ export function TalentScreen({ profile, onBack, onUpdated, embedded = false }: P
|
||||
}
|
||||
}
|
||||
|
||||
const workshopState = useMemo<DualScreenWorkshopState | null>(() => {
|
||||
if (!isEffectClass) return null
|
||||
return {
|
||||
const workshopState: DualScreenWorkshopState | null = isEffectClass
|
||||
? {
|
||||
mode: 'talents',
|
||||
title: 'Spell Effects',
|
||||
subtitle: `${selectedEffects.length}/${capacity} active`,
|
||||
@@ -140,7 +131,7 @@ export function TalentScreen({ profile, onBack, onUpdated, embedded = false }: P
|
||||
status: talent.rank > 0 ? 'Selected' : '',
|
||||
})),
|
||||
}
|
||||
}, [capacity, gameClass.talents, isEffectClass, selectedEffects.length, selectedTalent])
|
||||
: null
|
||||
|
||||
useDualScreenWorkshopPublisher(workshopState, dualScreenEnabled)
|
||||
|
||||
@@ -265,16 +256,16 @@ export function TalentScreen({ profile, onBack, onUpdated, embedded = false }: P
|
||||
{effectPageCount > 1 && (
|
||||
<div className="effect-pager">
|
||||
<button
|
||||
disabled={effectPage === 0}
|
||||
onClick={() => setEffectPage((page) => Math.max(0, page - 1))}
|
||||
disabled={activeEffectPage === 0}
|
||||
onClick={() => setEffectPage(Math.max(0, activeEffectPage - 1))}
|
||||
type="button"
|
||||
>
|
||||
Prev
|
||||
</button>
|
||||
<span>{effectPage + 1}/{effectPageCount}</span>
|
||||
<span>{activeEffectPage + 1}/{effectPageCount}</span>
|
||||
<button
|
||||
disabled={effectPage >= effectPageCount - 1}
|
||||
onClick={() => setEffectPage((page) => Math.min(effectPageCount - 1, page + 1))}
|
||||
disabled={activeEffectPage >= effectPageCount - 1}
|
||||
onClick={() => setEffectPage(Math.min(effectPageCount - 1, activeEffectPage + 1))}
|
||||
type="button"
|
||||
>
|
||||
Next
|
||||
|
||||
+137
-53
@@ -9,7 +9,7 @@ import {
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
import type { CombatLogEntry, PartyMember, Spell } from './game'
|
||||
import type { PartyMember, Spell } from './game'
|
||||
import {
|
||||
getNativeDisplays,
|
||||
hasNativeDualScreenBridge,
|
||||
@@ -21,11 +21,15 @@ import {
|
||||
type InputAction,
|
||||
} from './input'
|
||||
import { ControllerBindingLabel } from './components/ControllerIcons'
|
||||
import { PartyMemberFrame } from './components/PartyFrames'
|
||||
import { groupFloatingTextsByMember } from './combat/combatPresentation'
|
||||
|
||||
const STORAGE_KEY = 'ashen-halls-dual-screen-enabled'
|
||||
const SNAPSHOT_KEY = 'ashen-halls-dual-screen-snapshot'
|
||||
const STARTUP_CHOICE_KEY = 'ashen-halls-dual-screen-startup-choice'
|
||||
const CHANNEL_NAME = 'ashen-halls-dual-screen'
|
||||
const COMBAT_PUBLISH_INTERVAL_MS = 100
|
||||
const COMBAT_SNAPSHOT_INTERVAL_MS = 1000
|
||||
|
||||
export type DualScreenCombatState = {
|
||||
difficultyName: string
|
||||
@@ -39,22 +43,39 @@ export type DualScreenCombatState = {
|
||||
encounterIndex: number
|
||||
encounterCount: number
|
||||
party: PartyMember[]
|
||||
opponentName?: string
|
||||
opponentClassName?: string
|
||||
opponentParty?: PartyMember[]
|
||||
opponentEnemyHealth?: number
|
||||
opponentBuffSummary?: string
|
||||
opponentDebuffSummary?: string
|
||||
floatingTexts: Array<{
|
||||
id: number
|
||||
memberId: string
|
||||
value: number
|
||||
}>
|
||||
partySize: number
|
||||
selectedId: string
|
||||
log: CombatLogEntry[]
|
||||
status: 'playing' | 'won' | 'lost' | 'part-complete' | 'marathon-choice' | 'upgrade-choice'
|
||||
resource: number
|
||||
maxResource: number
|
||||
resourceName: string
|
||||
playerIsAlive: boolean
|
||||
spells: Array<(Spell & { slotIndex: number; remaining: number }) | null>
|
||||
activeDevice: 'pc' | 'controller'
|
||||
bindings: Record<InputAction, string>
|
||||
controllerIconStyle: ControllerIconStyle
|
||||
directPartyTargeting: boolean
|
||||
paused: boolean
|
||||
targetGroup: 0 | 1 | 2
|
||||
speedMultiplier: 1 | 2
|
||||
stadium?: {
|
||||
dampeningPercent: number
|
||||
roundIndex: number
|
||||
playerWins: number
|
||||
opponentWins: number
|
||||
survivalSeconds: number
|
||||
opponentSurvivalSeconds: number
|
||||
}
|
||||
}
|
||||
|
||||
export type DualScreenWorkshopState = {
|
||||
@@ -119,11 +140,9 @@ function loadRecentSnapshot() {
|
||||
}
|
||||
}
|
||||
|
||||
function memberHotEffects(member: PartyMember) {
|
||||
if (member.hotEffects?.length) return member.hotEffects
|
||||
return member.hotTicks > 0
|
||||
? [{ id: 'legacy-renew', spellId: 'legacy-renew', label: 'Renew', ticks: member.hotTicks, power: 6 }]
|
||||
: []
|
||||
function formatDualTime(seconds: number) {
|
||||
const total = Math.max(0, Math.floor(seconds))
|
||||
return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export function DualScreenProvider({ children }: { children: ReactNode }) {
|
||||
@@ -289,19 +308,19 @@ export function useDualScreenPublisher(
|
||||
}
|
||||
}
|
||||
publish()
|
||||
saveSnapshot(stateRef.current)
|
||||
const publishTimer = window.setInterval(publish, COMBAT_PUBLISH_INTERVAL_MS)
|
||||
const snapshotTimer = window.setInterval(() => {
|
||||
saveSnapshot(stateRef.current)
|
||||
}, COMBAT_SNAPSHOT_INTERVAL_MS)
|
||||
return () => {
|
||||
window.clearInterval(publishTimer)
|
||||
window.clearInterval(snapshotTimer)
|
||||
saveSnapshot(stateRef.current)
|
||||
channel.postMessage({ type: 'combat-ended' } satisfies DualScreenMessage)
|
||||
channel.close()
|
||||
}
|
||||
}, [enabled])
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return
|
||||
saveSnapshot(state)
|
||||
const channel = createChannel()
|
||||
channel?.postMessage({ type: 'combat-state', state } satisfies DualScreenMessage)
|
||||
channel?.close()
|
||||
}, [enabled, state])
|
||||
}
|
||||
|
||||
export function useDualScreenWorkshopPublisher(
|
||||
@@ -426,17 +445,63 @@ export function DualScreenBottomDisplay() {
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="dual-bottom-display">
|
||||
<main className={`dual-bottom-display ${state.opponentParty ? 'pvp-opponent-bottom-display' : ''}`}>
|
||||
<header className="dual-controls-header">
|
||||
<div>
|
||||
<p className="eyebrow">{state.difficultyName} {state.contentName}</p>
|
||||
<h1>{state.dungeonName}</h1>
|
||||
<p className="eyebrow">{state.opponentParty ? 'Opponent View' : `${state.difficultyName} ${state.contentName}`}</p>
|
||||
<h1>{state.opponentParty ? state.opponentName : state.dungeonName}</h1>
|
||||
{state.opponentParty && <small>{state.opponentClassName}</small>}
|
||||
</div>
|
||||
<div className="dual-controls-progress">
|
||||
<span>Encounter {state.encounterIndex + 1}/{state.encounterCount}</span>
|
||||
<span>{state.stadium ? `Round ${state.stadium.roundIndex} | ${state.stadium.playerWins}-${state.stadium.opponentWins}` : state.opponentParty ? 'Live PvP' : `Encounter ${state.encounterIndex + 1}/${state.encounterCount}`}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{state.opponentParty ? (
|
||||
<>
|
||||
{state.stadium ? (
|
||||
<section className="dual-opponent-progress stadium">
|
||||
<div>
|
||||
<p className="eyebrow">Dampening</p>
|
||||
<strong>{state.stadium.dampeningPercent}%</strong>
|
||||
</div>
|
||||
<div>
|
||||
<p className="eyebrow">Survival</p>
|
||||
<strong>{formatDualTime(state.stadium.opponentSurvivalSeconds)}</strong>
|
||||
</div>
|
||||
</section>
|
||||
) : (
|
||||
<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) => (
|
||||
<PartyMemberFrame
|
||||
as="article"
|
||||
baseClassName="dual-opponent-member"
|
||||
effectMode="compact"
|
||||
key={member.id}
|
||||
member={member}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section className="dual-opponent-effects">
|
||||
<span>Buffs: {state.opponentBuffSummary || 'none'}</span>
|
||||
<span>Debuffs: {state.opponentDebuffSummary || 'none'}</span>
|
||||
</section>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
|
||||
<section className="dual-controls-resource">
|
||||
<div>
|
||||
<p className="eyebrow">Active Target</p>
|
||||
@@ -537,6 +602,8 @@ export function DualScreenBottomDisplay() {
|
||||
)
|
||||
})}
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -544,14 +611,20 @@ export function DualScreenBottomDisplay() {
|
||||
export function DualScreenTopCombat({
|
||||
state,
|
||||
onSelectTarget,
|
||||
onCastSpell,
|
||||
}: {
|
||||
state: DualScreenCombatState
|
||||
onSelectTarget: (id: string) => void
|
||||
onCastSpell?: (spell: Spell) => void
|
||||
}) {
|
||||
const enemyPercent = Math.max(
|
||||
0,
|
||||
(state.encounterHealth / state.encounterMaxHealth) * 100,
|
||||
)
|
||||
const floatingTextsByMember = useMemo(
|
||||
() => groupFloatingTextsByMember(state.floatingTexts),
|
||||
[state.floatingTexts],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="dual-top-main">
|
||||
@@ -580,45 +653,56 @@ export function DualScreenTopCombat({
|
||||
const inCurrentTargetGroup = index >= groupStart && index < groupStart + 6
|
||||
const targetBinding = state.directPartyTargeting && inCurrentTargetGroup ? state.bindings[targetAction] : null
|
||||
return (
|
||||
<button
|
||||
className={`dual-top-member ${state.selectedId === member.id ? 'selected' : ''} ${member.health <= 0 ? 'dead' : ''}`}
|
||||
data-party-member-id={member.id}
|
||||
<PartyMemberFrame
|
||||
baseClassName="dual-top-member"
|
||||
controllerIconStyle={state.controllerIconStyle}
|
||||
effectMode="compact"
|
||||
floatingTexts={floatingTextsByMember.get(member.id) ?? []}
|
||||
key={member.id}
|
||||
onClick={() => onSelectTarget(member.id)}
|
||||
type="button"
|
||||
>
|
||||
<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}%` }} />
|
||||
)}
|
||||
<em className="health-text">{Math.ceil(member.health)} / {member.maxHealth}</em>
|
||||
</div>
|
||||
{state.directPartyTargeting && targetBinding && (
|
||||
<div className="member-target-key">
|
||||
<ControllerBindingLabel
|
||||
binding={targetBinding}
|
||||
iconStyle={state.controllerIconStyle}
|
||||
/>
|
||||
</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>
|
||||
</button>
|
||||
member={member}
|
||||
onSelect={onSelectTarget}
|
||||
selected={state.selectedId === member.id}
|
||||
targetBinding={targetBinding}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="dual-top-spell-strip">
|
||||
{state.spells.map((spell, slotIndex) => {
|
||||
if (!spell) return <div className="dual-top-spell empty" key={`empty-${slotIndex}`} />
|
||||
const percent = spell.remaining > 0
|
||||
? Math.min(100, (spell.remaining / Math.max(1, spell.cooldown)) * 100)
|
||||
: 0
|
||||
return (
|
||||
<button
|
||||
className="dual-top-spell"
|
||||
disabled={
|
||||
!state.playerIsAlive
|
||||
|| state.resource < spell.cost
|
||||
|| spell.remaining > 0
|
||||
|| state.status !== 'playing'
|
||||
|| state.paused
|
||||
}
|
||||
key={spell.id}
|
||||
onClick={() => onCastSpell?.(spell)}
|
||||
type="button"
|
||||
>
|
||||
<span className={`spell-icon spell-${spell.kind}`}>{spell.glyph}</span>
|
||||
{spell.remaining > 0 && <i style={{ height: `${percent}%` }} />}
|
||||
{spell.remaining > 0 && <small>{spell.remaining.toFixed(0)}</small>}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
<div className="dual-top-resource">
|
||||
<strong>{state.resourceName} {Math.floor(state.resource)} / {state.maxResource}</strong>
|
||||
<div className="bar mana-bar">
|
||||
<span style={{ width: `${(state.resource / state.maxResource) * 100}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+29
-10
@@ -184,17 +184,25 @@ export const ENCOUNTERS: Encounter[] = [
|
||||
]
|
||||
|
||||
export function partyDamageOutput(party: PartyMember[], baseDamage: number) {
|
||||
const livingCount = party.filter((member) => member.health > 0).length
|
||||
let livingCount = 0
|
||||
for (const member of party) {
|
||||
if (member.health > 0) livingCount += 1
|
||||
}
|
||||
return Math.round(baseDamage * (livingCount / Math.max(1, party.length)))
|
||||
}
|
||||
|
||||
export function tankPressureTargets(party: PartyMember[]) {
|
||||
const living = party.filter((member) => member.health > 0)
|
||||
const tanks = living.filter((member) => member.role === 'Tank')
|
||||
const tanks: PartyMember[] = []
|
||||
let damageDealer: PartyMember | undefined
|
||||
for (const member of party) {
|
||||
if (member.health <= 0) continue
|
||||
if (member.role === 'Tank') {
|
||||
tanks.push(member)
|
||||
} else if (member.role === 'Damage' && (!damageDealer || member.health > damageDealer.health)) {
|
||||
damageDealer = member
|
||||
}
|
||||
}
|
||||
if (tanks.length > 0) return { targets: tanks, multiplier: 1 }
|
||||
const damageDealer = living
|
||||
.filter((member) => member.role === 'Damage')
|
||||
.sort((left, right) => right.health - left.health)[0]
|
||||
return {
|
||||
targets: damageDealer ? [damageDealer] : [],
|
||||
multiplier: TANKLESS_DAMAGE_MULTIPLIER,
|
||||
@@ -202,8 +210,19 @@ export function tankPressureTargets(party: PartyMember[]) {
|
||||
}
|
||||
|
||||
export function groupHealTargets(party: PartyMember[], targetCount = DEFAULT_GROUP_HEAL_TARGETS) {
|
||||
return party
|
||||
.filter((member) => member.health > 0)
|
||||
.sort((left, right) => (left.health / left.maxHealth) - (right.health / right.maxHealth))
|
||||
.slice(0, targetCount)
|
||||
const targets: PartyMember[] = []
|
||||
for (const member of party) {
|
||||
if (member.health <= 0) continue
|
||||
const healthRatio = member.health / member.maxHealth
|
||||
let insertAt = targets.length
|
||||
while (
|
||||
insertAt > 0
|
||||
&& healthRatio < targets[insertAt - 1].health / targets[insertAt - 1].maxHealth
|
||||
) {
|
||||
insertAt -= 1
|
||||
}
|
||||
targets.splice(insertAt, 0, member)
|
||||
if (targets.length > targetCount) targets.pop()
|
||||
}
|
||||
return targets
|
||||
}
|
||||
|
||||
+109
-51
@@ -1,4 +1,14 @@
|
||||
import starterProfile from './offline-starter-profile.json'
|
||||
import { bundledCatalogHash } from './offline-catalog-meta'
|
||||
import {
|
||||
catchUpExperienceReward as catchUpExperienceRewardForTarget,
|
||||
coinDropQuantity,
|
||||
experienceForLevel,
|
||||
roguelikeCoinItemLevel,
|
||||
scaledCurrentLevelExperience,
|
||||
scaledPvpBossExperience,
|
||||
scaledPvpFightExperience,
|
||||
} from './shared/rewardRules.mjs'
|
||||
import type {
|
||||
Account,
|
||||
AuthSession,
|
||||
@@ -36,7 +46,8 @@ export interface GameRepository {
|
||||
durationSeconds: number,
|
||||
options?: {
|
||||
bossesCleared?: number
|
||||
experienceMode?: 'default' | 'pvp-boss-quarter-level'
|
||||
experienceMode?: 'default' | 'pvp-boss-quarter-level' | 'pvp-fight-twelfth-level' | 'pvp-match-win-half-level' | 'pvp-stadium-round-win-quarter-level' | 'pvp-stadium-round-loss-tenth-level' | 'pvp-stadium-match-half-level'
|
||||
fightsCleared?: number
|
||||
lootSourceEncounterId?: number
|
||||
roguelikeStage?: number
|
||||
},
|
||||
@@ -82,6 +93,12 @@ type OnlineCache = {
|
||||
dirty: boolean
|
||||
}
|
||||
|
||||
type CatalogCache = {
|
||||
version: 1
|
||||
hash: string
|
||||
profile: CharacterProfile
|
||||
}
|
||||
|
||||
export type CloudSyncStatus = {
|
||||
available: boolean
|
||||
dirty: boolean
|
||||
@@ -102,9 +119,12 @@ type LocalSaveStore = {
|
||||
const modeKey = 'chronicle.repositoryMode'
|
||||
const offlineSaveKey = 'chronicle.offlineSave.v1'
|
||||
const onlineCacheKey = 'chronicle.onlineCache.v1'
|
||||
const catalogCacheKey = 'chronicle.catalog.v1'
|
||||
const catalogBundleKey = 'chronicle.catalog.bundleHash.v1'
|
||||
const authTokenKey = 'chronicle.authToken.v1'
|
||||
const offlineAccount = { id: -1, username: 'Offline' }
|
||||
const ABILITY_SLOT_COUNT = 6
|
||||
let activeCatalogCache: CatalogCache | null = null
|
||||
|
||||
function clone<T>(value: T): T {
|
||||
return structuredClone(value)
|
||||
@@ -281,8 +301,46 @@ function clearOnlineCache() {
|
||||
localStorage.removeItem(onlineCacheKey)
|
||||
}
|
||||
|
||||
function bundledCatalog(): CatalogCache {
|
||||
return {
|
||||
version: 1,
|
||||
hash: bundledCatalogHash,
|
||||
profile: starterProfile as CharacterProfile,
|
||||
}
|
||||
}
|
||||
|
||||
function readCatalogCache(): CatalogCache | null {
|
||||
if (localStorage.getItem(catalogBundleKey) !== bundledCatalogHash) {
|
||||
localStorage.removeItem(catalogCacheKey)
|
||||
localStorage.setItem(catalogBundleKey, bundledCatalogHash)
|
||||
activeCatalogCache = null
|
||||
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))
|
||||
activeCatalogCache = cache
|
||||
}
|
||||
|
||||
function activeCatalog(): CatalogCache {
|
||||
if (activeCatalogCache) return activeCatalogCache
|
||||
activeCatalogCache = readCatalogCache() ?? bundledCatalog()
|
||||
return activeCatalogCache
|
||||
}
|
||||
|
||||
function buildProfile(save: OfflineSave): CharacterProfile {
|
||||
const static_ = clone(starterProfile) as CharacterProfile
|
||||
const static_ = clone(activeCatalog().profile)
|
||||
const cd = save.characters[save.activeClassId]
|
||||
const gameClass = static_.classes.find((c) => c.id === save.activeClassId)!
|
||||
|
||||
@@ -410,10 +468,6 @@ function selectUpgradeRecipe(
|
||||
return candidates.find((recipe) => recipe.item.itemLevel === nextItemLevel)
|
||||
}
|
||||
|
||||
function experienceForLevel(level: number) {
|
||||
return (level - 1) * (level - 1) * 100
|
||||
}
|
||||
|
||||
function catchUpExperienceReward(
|
||||
baseReward: number,
|
||||
currentExperience: number,
|
||||
@@ -421,11 +475,11 @@ function catchUpExperienceReward(
|
||||
targetLevel: number,
|
||||
) {
|
||||
if (targetLevel <= currentLevel) return baseReward
|
||||
const targetExperience = experienceForLevel(targetLevel)
|
||||
const gap = Math.max(0, targetExperience - currentExperience)
|
||||
if (gap <= 0) return baseReward
|
||||
const doubledBase = Math.min(baseReward, Math.ceil(gap / 2))
|
||||
return doubledBase * 2 + (baseReward - doubledBase)
|
||||
return catchUpExperienceRewardForTarget(
|
||||
baseReward,
|
||||
currentExperience,
|
||||
experienceForLevel(targetLevel),
|
||||
)
|
||||
}
|
||||
|
||||
function highestOtherClassLevel(save: OfflineSave) {
|
||||
@@ -435,31 +489,6 @@ function highestOtherClassLevel(save: OfflineSave) {
|
||||
.reduce((highest, [, character]) => Math.max(highest, character.level), 0)
|
||||
}
|
||||
|
||||
function scaledPvpBossExperience(
|
||||
startingExperience: number,
|
||||
startingLevel: number,
|
||||
bossesCleared: number,
|
||||
maxLevel: number,
|
||||
targetLevel = startingLevel,
|
||||
) {
|
||||
let experience = startingExperience
|
||||
let level = startingLevel
|
||||
const maxExperience = experienceForLevel(maxLevel)
|
||||
for (let bossIndex = 0; bossIndex < bossesCleared && experience < maxExperience; bossIndex += 1) {
|
||||
const currentLevelFloor = experienceForLevel(level)
|
||||
const nextLevelExperience = level >= maxLevel
|
||||
? maxExperience
|
||||
: experienceForLevel(level + 1)
|
||||
const levelBand = Math.max(1, nextLevelExperience - currentLevelFloor)
|
||||
const rewardRate = targetLevel > level ? 0.5 : 0.25
|
||||
experience = Math.min(maxExperience, experience + Math.round(levelBand * rewardRate))
|
||||
while (level < maxLevel && experienceForLevel(level + 1) <= experience) {
|
||||
level += 1
|
||||
}
|
||||
}
|
||||
return { experience, level }
|
||||
}
|
||||
|
||||
function talentEffectCapacity(level: number) {
|
||||
return Math.min(4, Math.max(0, Math.floor(level / 5)))
|
||||
}
|
||||
@@ -541,17 +570,6 @@ function rollWeightedLootEntry<T extends { dropWeight: number }>(entries: T[]):
|
||||
return entries[entries.length - 1]
|
||||
}
|
||||
|
||||
function coinDropQuantity() {
|
||||
const roll = Math.random()
|
||||
if (roll < 0.15) return 3
|
||||
if (roll < 0.5) return 2
|
||||
return 1
|
||||
}
|
||||
|
||||
function roguelikeCoinItemLevel(stage: number) {
|
||||
return Math.min(25, 5 + Math.max(0, Math.floor(stage / 5)) * 5)
|
||||
}
|
||||
|
||||
function awardRoguelikeCoin(
|
||||
profile: CharacterProfile,
|
||||
sourceEncounterId: number | undefined,
|
||||
@@ -628,7 +646,7 @@ function getApiBaseUrl(path: string): string {
|
||||
return ''
|
||||
}
|
||||
|
||||
async function requestJson<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
export async function requestGameApiJson<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const baseUrl = getApiBaseUrl(path)
|
||||
const url = baseUrl ? `${baseUrl}${path}` : path
|
||||
const headers = new Headers(init?.headers)
|
||||
@@ -653,10 +671,31 @@ async function requestJson<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
return body
|
||||
}
|
||||
|
||||
async function requestJson<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
return requestGameApiJson(path, init)
|
||||
}
|
||||
|
||||
function isNetworkError(reason: unknown): reason is NetworkError {
|
||||
return reason instanceof Error && Boolean((reason as NetworkError).network)
|
||||
}
|
||||
|
||||
async function loadServerCatalog(): Promise<CatalogCache> {
|
||||
return requestJson('/api/catalog')
|
||||
}
|
||||
|
||||
async function refreshCatalogFromServer(): Promise<CatalogCache | null> {
|
||||
try {
|
||||
const catalog = await loadServerCatalog()
|
||||
if (catalog.version !== 1 || !catalog.hash || !catalog.profile) return null
|
||||
if (catalog.hash !== activeCatalog().hash || !readCatalogCache()) {
|
||||
writeCatalogCache(catalog)
|
||||
}
|
||||
return catalog
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function cachedOnlineSession(): AuthSession | null {
|
||||
const cache = readOnlineCache()
|
||||
if (!cache) return null
|
||||
@@ -698,6 +737,7 @@ async function pushServerSyncSave(save: OfflineSave): Promise<{ profile: Charact
|
||||
async function finalizeOnlineSession(session: AuthSession): Promise<AuthSession> {
|
||||
const cache = readOnlineCache()
|
||||
if (session.token) writeAuthToken(session.token)
|
||||
await refreshCatalogFromServer()
|
||||
if (!session.account || !session.profile) {
|
||||
if (session.account && cache?.account.id === session.account.id) {
|
||||
return {
|
||||
@@ -848,7 +888,7 @@ const serverRepository: GameRepository = {
|
||||
}
|
||||
|
||||
function emptyCharacterData(classId: number): CharacterData {
|
||||
const static_ = clone(starterProfile) as CharacterProfile
|
||||
const static_ = clone(activeCatalog().profile)
|
||||
const gc = static_.classes.find((c) => c.id === classId)!
|
||||
const talentRanks: Record<string, number> = {}
|
||||
for (const t of gc.talents) talentRanks[String(t.id)] = 0
|
||||
@@ -1077,7 +1117,23 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
|
||||
const bossesCleared = Math.max(0, Math.floor(options?.bossesCleared ?? encountersCleared / 3))
|
||||
const scaledReward = options?.experienceMode === 'pvp-boss-quarter-level'
|
||||
? scaledPvpBossExperience(previousExperience, previousLevel, bossesCleared, profile.maxLevel, highestOtherClassLevel(save))
|
||||
: null
|
||||
: options?.experienceMode === 'pvp-fight-twelfth-level'
|
||||
? scaledPvpFightExperience(
|
||||
previousExperience,
|
||||
previousLevel,
|
||||
Math.max(0, Math.floor(options.fightsCleared ?? encountersCleared)),
|
||||
profile.maxLevel,
|
||||
highestOtherClassLevel(save),
|
||||
)
|
||||
: options?.experienceMode === 'pvp-match-win-half-level'
|
||||
? scaledCurrentLevelExperience(previousExperience, previousLevel, profile.maxLevel, 0.5)
|
||||
: options?.experienceMode === 'pvp-stadium-round-win-quarter-level'
|
||||
? scaledCurrentLevelExperience(previousExperience, previousLevel, profile.maxLevel, 0.25)
|
||||
: options?.experienceMode === 'pvp-stadium-round-loss-tenth-level'
|
||||
? scaledCurrentLevelExperience(previousExperience, previousLevel, profile.maxLevel, 0.1)
|
||||
: options?.experienceMode === 'pvp-stadium-match-half-level'
|
||||
? scaledCurrentLevelExperience(previousExperience, previousLevel, profile.maxLevel, 0.5)
|
||||
: null
|
||||
const baseRoguelikeReward = Math.round(dungeon.experienceReward * difficulty.experienceMultiplier * (encountersCleared / 3))
|
||||
const newExperience = scaledReward
|
||||
? scaledReward.experience
|
||||
@@ -1544,7 +1600,9 @@ export async function syncCloudSave(): Promise<CharacterProfile> {
|
||||
if (!cache) {
|
||||
throw new Error('No signed-in save is available for cloud sync.')
|
||||
}
|
||||
await refreshCatalogFromServer()
|
||||
const synced = await pushServerSyncSave(cache.save)
|
||||
await refreshCatalogFromServer()
|
||||
writeOnlineCache({
|
||||
version: 1,
|
||||
account: cache.account,
|
||||
@@ -1552,7 +1610,7 @@ export async function syncCloudSave(): Promise<CharacterProfile> {
|
||||
dirty: false,
|
||||
})
|
||||
writeMode('online')
|
||||
return synced.profile
|
||||
return buildProfile(synced.save)
|
||||
}
|
||||
|
||||
export function selectOnlineMode() {
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
type DeadlineTimerOptions = {
|
||||
initialSeconds: number
|
||||
active: boolean
|
||||
intervalMs?: number
|
||||
onExpire: () => void
|
||||
}
|
||||
|
||||
type RoundCountdownOptions = {
|
||||
seconds: number
|
||||
active: boolean
|
||||
intervalMs?: number
|
||||
onComplete: () => void
|
||||
}
|
||||
|
||||
export function useDeadlineTimer({
|
||||
initialSeconds,
|
||||
active,
|
||||
intervalMs = 100,
|
||||
onExpire,
|
||||
}: DeadlineTimerOptions) {
|
||||
const [timeLeft, setTimeLeft] = useState(initialSeconds)
|
||||
const deadlineRef = useRef(0)
|
||||
const timerRef = useRef<number | null>(null)
|
||||
const onExpireRef = useRef(onExpire)
|
||||
|
||||
useEffect(() => {
|
||||
onExpireRef.current = onExpire
|
||||
}, [onExpire])
|
||||
|
||||
const clearTimer = useCallback(() => {
|
||||
if (timerRef.current === null) return
|
||||
window.clearInterval(timerRef.current)
|
||||
timerRef.current = null
|
||||
}, [])
|
||||
|
||||
const tick = useCallback(() => {
|
||||
if (deadlineRef.current <= 0) return
|
||||
const remaining = Math.max(0, (deadlineRef.current - Date.now()) / 1000)
|
||||
setTimeLeft(remaining)
|
||||
if (remaining > 0) return
|
||||
deadlineRef.current = 0
|
||||
clearTimer()
|
||||
onExpireRef.current()
|
||||
}, [clearTimer])
|
||||
|
||||
const start = useCallback((seconds = initialSeconds) => {
|
||||
clearTimer()
|
||||
deadlineRef.current = Date.now() + seconds * 1000
|
||||
setTimeLeft(seconds)
|
||||
timerRef.current = window.setInterval(tick, intervalMs)
|
||||
}, [clearTimer, initialSeconds, intervalMs, tick])
|
||||
|
||||
const clear = useCallback(() => {
|
||||
deadlineRef.current = 0
|
||||
clearTimer()
|
||||
}, [clearTimer])
|
||||
|
||||
const reset = useCallback((seconds = initialSeconds) => {
|
||||
clear()
|
||||
setTimeLeft(seconds)
|
||||
}, [clear, initialSeconds])
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) clearTimer()
|
||||
}, [active, clearTimer])
|
||||
|
||||
useEffect(() => () => clearTimer(), [clearTimer])
|
||||
|
||||
return {
|
||||
timeLeft,
|
||||
start,
|
||||
clear,
|
||||
reset,
|
||||
}
|
||||
}
|
||||
|
||||
export function useRoundCountdown({
|
||||
seconds,
|
||||
active,
|
||||
intervalMs = 100,
|
||||
onComplete,
|
||||
}: RoundCountdownOptions) {
|
||||
return useDeadlineTimer({
|
||||
initialSeconds: seconds,
|
||||
active,
|
||||
intervalMs,
|
||||
onExpire: onComplete,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
appendFloatingText,
|
||||
groupFloatingTextsByMember,
|
||||
stripFloatingTextSide,
|
||||
type BasicFloatingCombatText,
|
||||
} from '../combat/combatPresentation'
|
||||
|
||||
type FloatingSide = 'player' | 'cpu'
|
||||
type SideFloatingCombatText = BasicFloatingCombatText & {
|
||||
side: FloatingSide
|
||||
}
|
||||
|
||||
type FloatingCombatTextOptions = {
|
||||
durationMs?: number
|
||||
}
|
||||
|
||||
const FLOATING_TEXT_CLEANUP_INTERVAL_MS = 100
|
||||
|
||||
export function useFloatingCombatText<TText extends BasicFloatingCombatText>({
|
||||
durationMs = 900,
|
||||
}: FloatingCombatTextOptions = {}) {
|
||||
const [floatingTexts, setFloatingTexts] = useState<TText[]>([])
|
||||
const nextId = useRef(1)
|
||||
const expirationsRef = useRef(new Map<number, number>())
|
||||
|
||||
const clearFloatingTexts = useCallback(() => {
|
||||
expirationsRef.current.clear()
|
||||
setFloatingTexts([])
|
||||
}, [])
|
||||
|
||||
const addFloatingText = useCallback((entry: Omit<TText, 'id'>) => {
|
||||
if (entry.value <= 0) return
|
||||
const id = nextId.current++
|
||||
expirationsRef.current.set(id, performance.now() + durationMs)
|
||||
setFloatingTexts((current) => appendFloatingText(current, { ...entry, id } as TText))
|
||||
}, [durationMs])
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => {
|
||||
const now = performance.now()
|
||||
let removed = false
|
||||
for (const [id, expiresAt] of expirationsRef.current) {
|
||||
if (expiresAt > now) continue
|
||||
expirationsRef.current.delete(id)
|
||||
removed = true
|
||||
}
|
||||
if (!removed) return
|
||||
setFloatingTexts((current) => current.filter((text) => expirationsRef.current.has(text.id)))
|
||||
}, FLOATING_TEXT_CLEANUP_INTERVAL_MS)
|
||||
return () => window.clearInterval(timer)
|
||||
}, [])
|
||||
|
||||
const floatingTextsByMember = useMemo(
|
||||
() => groupFloatingTextsByMember(floatingTexts),
|
||||
[floatingTexts],
|
||||
)
|
||||
|
||||
return {
|
||||
floatingTexts,
|
||||
floatingTextsByMember,
|
||||
addFloatingText,
|
||||
clearFloatingTexts,
|
||||
}
|
||||
}
|
||||
|
||||
export function useSidedFloatingCombatText(options?: FloatingCombatTextOptions) {
|
||||
const {
|
||||
floatingTexts,
|
||||
addFloatingText,
|
||||
clearFloatingTexts,
|
||||
} = useFloatingCombatText<SideFloatingCombatText>(options)
|
||||
const playerFloatingTexts = useMemo(
|
||||
() => floatingTexts.filter((entry) => entry.side === 'player'),
|
||||
[floatingTexts],
|
||||
)
|
||||
const cpuFloatingTexts = useMemo(
|
||||
() => floatingTexts.filter((entry) => entry.side === 'cpu'),
|
||||
[floatingTexts],
|
||||
)
|
||||
const playerFloatingTextsByMember = useMemo(
|
||||
() => groupFloatingTextsByMember(playerFloatingTexts),
|
||||
[playerFloatingTexts],
|
||||
)
|
||||
const cpuFloatingTextsByMember = useMemo(
|
||||
() => groupFloatingTextsByMember(cpuFloatingTexts),
|
||||
[cpuFloatingTexts],
|
||||
)
|
||||
const dualScreenFloatingTexts = useMemo(
|
||||
() => stripFloatingTextSide(playerFloatingTexts),
|
||||
[playerFloatingTexts],
|
||||
)
|
||||
|
||||
return {
|
||||
floatingTexts,
|
||||
playerFloatingTexts,
|
||||
cpuFloatingTexts,
|
||||
playerFloatingTextsByMember,
|
||||
cpuFloatingTextsByMember,
|
||||
dualScreenFloatingTexts,
|
||||
addFloatingText,
|
||||
clearFloatingTexts,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useCallback, type RefObject } from 'react'
|
||||
import type { PartyMember } from '../game'
|
||||
import {
|
||||
selectDirectionalPartyTarget,
|
||||
selectDirectPartyTarget,
|
||||
selectRelativePartyTarget,
|
||||
type NavigateAction,
|
||||
} from '../combat/targeting'
|
||||
import type { InputAction } from '../input'
|
||||
|
||||
type PartyTargetingOptions = {
|
||||
getParty: () => PartyMember[]
|
||||
selectedIdRef: RefObject<string>
|
||||
setSelectedTargetId: (id: string) => void
|
||||
columns: number
|
||||
livingOnly?: boolean
|
||||
relativeLivingOnly?: boolean
|
||||
directionalLivingOnly?: boolean
|
||||
directLivingOnly?: boolean
|
||||
directTargetGroup?: number
|
||||
}
|
||||
|
||||
export function usePartyTargeting({
|
||||
getParty,
|
||||
selectedIdRef,
|
||||
setSelectedTargetId,
|
||||
columns,
|
||||
livingOnly = true,
|
||||
relativeLivingOnly = livingOnly,
|
||||
directionalLivingOnly = livingOnly,
|
||||
directLivingOnly = livingOnly,
|
||||
directTargetGroup = 0,
|
||||
}: PartyTargetingOptions) {
|
||||
const selectRelativeTarget = useCallback((direction: -1 | 1) => {
|
||||
const targetId = selectRelativePartyTarget(getParty(), selectedIdRef.current, direction, {
|
||||
livingOnly: relativeLivingOnly,
|
||||
})
|
||||
if (targetId) setSelectedTargetId(targetId)
|
||||
}, [getParty, relativeLivingOnly, selectedIdRef, setSelectedTargetId])
|
||||
|
||||
const selectDirectionalTarget = useCallback((action: InputAction) => {
|
||||
const targetId = selectDirectionalPartyTarget(
|
||||
getParty(),
|
||||
selectedIdRef.current,
|
||||
action as NavigateAction,
|
||||
columns,
|
||||
{ livingOnly: directionalLivingOnly },
|
||||
)
|
||||
if (targetId) setSelectedTargetId(targetId)
|
||||
}, [columns, directionalLivingOnly, getParty, selectedIdRef, setSelectedTargetId])
|
||||
|
||||
const selectDirectTarget = useCallback((slot: number) => {
|
||||
const targetId = selectDirectPartyTarget(getParty(), slot, {
|
||||
livingOnly: directLivingOnly,
|
||||
targetGroup: directTargetGroup,
|
||||
})
|
||||
if (targetId) setSelectedTargetId(targetId)
|
||||
}, [directLivingOnly, directTargetGroup, getParty, setSelectedTargetId])
|
||||
|
||||
return {
|
||||
selectRelativeTarget,
|
||||
selectDirectionalTarget,
|
||||
selectDirectTarget,
|
||||
}
|
||||
}
|
||||
+105
-23
@@ -9,6 +9,7 @@ import {
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
|
||||
export type InputDevice = 'pc' | 'controller'
|
||||
export type ControllerIconStyle = 'xbox' | 'playstation' | 'nintendo'
|
||||
@@ -125,6 +126,12 @@ const STORAGE_KEY = 'ashen-halls-input-bindings-v1'
|
||||
const PREFERENCES_STORAGE_KEY = 'ashen-halls-input-preferences-v1'
|
||||
const GAME_ACTION_EVENT = 'ashen-halls-game-action'
|
||||
const NATIVE_CONTROLLER_EVENT = 'ashen-halls-native-controller'
|
||||
const FOCUSABLE_SELECTOR = 'button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex]:not([tabindex="-1"])'
|
||||
const GAMEPAD_COMBAT_POLL_MS = 1000 / 60
|
||||
const GAMEPAD_MENU_POLL_MS = 1000 / 30
|
||||
const GAMEPAD_BROWSER_DISCONNECTED_POLL_MS = 250
|
||||
|
||||
let lastControllerFocus: HTMLElement | null = null
|
||||
|
||||
type CaptureState = {
|
||||
device: InputDevice
|
||||
@@ -234,25 +241,41 @@ function focusableElements() {
|
||||
).find(isVisible)
|
||||
const scope: ParentNode = keyboard ?? pauseMenu ?? dialog ?? document
|
||||
return Array.from(
|
||||
scope.querySelectorAll<HTMLElement>(
|
||||
'button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex]:not([tabindex="-1"])',
|
||||
),
|
||||
scope.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR),
|
||||
).filter(isVisible)
|
||||
}
|
||||
|
||||
function rememberFocusableControl(element: HTMLElement) {
|
||||
lastControllerFocus = element
|
||||
}
|
||||
|
||||
function focusControl(element: HTMLElement) {
|
||||
rememberFocusableControl(element)
|
||||
element.focus({ preventScroll: true })
|
||||
}
|
||||
|
||||
function currentFocusableControl(candidates = focusableElements()) {
|
||||
const active = document.activeElement
|
||||
if (active instanceof HTMLElement && candidates.includes(active)) {
|
||||
rememberFocusableControl(active)
|
||||
return active
|
||||
}
|
||||
if (lastControllerFocus && candidates.includes(lastControllerFocus) && isVisible(lastControllerFocus)) {
|
||||
return lastControllerFocus
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function focusFirstControl() {
|
||||
const first = focusableElements()[0]
|
||||
first?.focus({ preventScroll: true })
|
||||
if (first) focusControl(first)
|
||||
return first
|
||||
}
|
||||
|
||||
function moveFocus(action: InputAction) {
|
||||
const candidates = focusableElements()
|
||||
if (candidates.length === 0) return
|
||||
const current = document.activeElement instanceof HTMLElement
|
||||
&& candidates.includes(document.activeElement)
|
||||
? document.activeElement
|
||||
: null
|
||||
const current = currentFocusableControl(candidates)
|
||||
if (!current) {
|
||||
focusFirstControl()
|
||||
return
|
||||
@@ -279,7 +302,7 @@ function moveFocus(action: InputAction) {
|
||||
|
||||
const next = ranked[0]?.candidate
|
||||
if (!next) return
|
||||
next.focus({ preventScroll: true })
|
||||
focusControl(next)
|
||||
}
|
||||
|
||||
function hasUiOverlay() {
|
||||
@@ -381,6 +404,14 @@ function gamepadTokens(gamepad: Gamepad) {
|
||||
return tokens
|
||||
}
|
||||
|
||||
function isCombatActive() {
|
||||
return Boolean(document.querySelector('[data-combat-active="true"]'))
|
||||
}
|
||||
|
||||
function firstConnectedGamepad() {
|
||||
return Array.from(navigator.getGamepads?.() ?? []).find(Boolean) ?? null
|
||||
}
|
||||
|
||||
function setInputValue(input: HTMLInputElement | HTMLTextAreaElement, nextValue: string) {
|
||||
const prototype = input instanceof HTMLTextAreaElement
|
||||
? HTMLTextAreaElement.prototype
|
||||
@@ -403,6 +434,7 @@ export function InputProvider({ children }: { children: ReactNode }) {
|
||||
const keyboardInputRef = useRef(keyboardInput)
|
||||
const previousTokensRef = useRef(new Set<string>())
|
||||
const repeatRef = useRef<Record<string, number>>({})
|
||||
const gamepadConnectedRef = useRef(Capacitor.isNativePlatform())
|
||||
|
||||
useEffect(() => {
|
||||
bindingsRef.current = bindings
|
||||
@@ -456,12 +488,12 @@ export function InputProvider({ children }: { children: ReactNode }) {
|
||||
if (action.startsWith('navigate')) {
|
||||
if (uiOverlay || !combatActive) moveFocus(action)
|
||||
} else if (action === 'confirm') {
|
||||
const active = document.activeElement
|
||||
const active = currentFocusableControl()
|
||||
if (isTextInput(active)) {
|
||||
setKeyboardInput(active)
|
||||
window.requestAnimationFrame(() => focusFirstControl())
|
||||
} else if (
|
||||
active instanceof HTMLElement
|
||||
active
|
||||
&& active.matches('button:not(:disabled), [role="button"]')
|
||||
&& isVisible(active)
|
||||
) {
|
||||
@@ -581,6 +613,29 @@ export function InputProvider({ children }: { children: ReactNode }) {
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [assignBinding, dispatchAction])
|
||||
|
||||
useEffect(() => {
|
||||
const onFocusIn = (event: FocusEvent) => {
|
||||
const target = event.target
|
||||
if (!(target instanceof HTMLElement)) return
|
||||
if (!target.matches(FOCUSABLE_SELECTOR) || !isVisible(target)) return
|
||||
rememberFocusableControl(target)
|
||||
}
|
||||
const onPointerDown = (event: PointerEvent) => {
|
||||
document.documentElement.dataset.inputDevice = 'pc'
|
||||
const target = event.target
|
||||
if (!(target instanceof Element)) return
|
||||
const control = target.closest<HTMLElement>(FOCUSABLE_SELECTOR)
|
||||
if (!control || !isVisible(control)) return
|
||||
rememberFocusableControl(control)
|
||||
}
|
||||
document.addEventListener('focusin', onFocusIn)
|
||||
document.addEventListener('pointerdown', onPointerDown, { capture: true })
|
||||
return () => {
|
||||
document.removeEventListener('focusin', onFocusIn)
|
||||
document.removeEventListener('pointerdown', onPointerDown, { capture: true })
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const listener = (event: Event) => {
|
||||
const detail = (event as CustomEvent<{ token: string; repeat?: boolean }>).detail
|
||||
@@ -595,16 +650,17 @@ export function InputProvider({ children }: { children: ReactNode }) {
|
||||
const combatActive = document.querySelector('[data-combat-active="true"]')
|
||||
if (combatActive) return
|
||||
const candidates = focusableElements()
|
||||
const active = document.activeElement
|
||||
const activeIsUsable = active instanceof HTMLElement
|
||||
&& candidates.includes(active)
|
||||
&& isVisible(active)
|
||||
const activeControl = currentFocusableControl(candidates)
|
||||
if (
|
||||
(!activeIsUsable || document.activeElement === document.body)
|
||||
(!activeControl || document.activeElement === document.body)
|
||||
&& !keyboardInputRef.current
|
||||
&& !captureRef.current
|
||||
) {
|
||||
focusFirstControl()
|
||||
if (activeControl) {
|
||||
focusControl(activeControl)
|
||||
} else {
|
||||
focusFirstControl()
|
||||
}
|
||||
}
|
||||
}
|
||||
const observer = new MutationObserver(() => {
|
||||
@@ -621,9 +677,21 @@ export function InputProvider({ children }: { children: ReactNode }) {
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let frame = 0
|
||||
const poll = (time: number) => {
|
||||
const gamepad = Array.from(navigator.getGamepads?.() ?? []).find(Boolean)
|
||||
let timer = 0
|
||||
const nextDelay = () => {
|
||||
if (!Capacitor.isNativePlatform() && !gamepadConnectedRef.current) {
|
||||
return GAMEPAD_BROWSER_DISCONNECTED_POLL_MS
|
||||
}
|
||||
return isCombatActive() ? GAMEPAD_COMBAT_POLL_MS : GAMEPAD_MENU_POLL_MS
|
||||
}
|
||||
const clearControllerState = () => {
|
||||
previousTokensRef.current = new Set()
|
||||
repeatRef.current = {}
|
||||
}
|
||||
const poll = () => {
|
||||
const time = performance.now()
|
||||
const gamepad = firstConnectedGamepad()
|
||||
gamepadConnectedRef.current = Capacitor.isNativePlatform() || Boolean(gamepad)
|
||||
const currentTokens = gamepad ? gamepadTokens(gamepad) : new Set<string>()
|
||||
const previousTokens = previousTokensRef.current
|
||||
|
||||
@@ -649,10 +717,24 @@ export function InputProvider({ children }: { children: ReactNode }) {
|
||||
if (!currentTokens.has(token)) delete repeatRef.current[token]
|
||||
})
|
||||
previousTokensRef.current = currentTokens
|
||||
frame = window.requestAnimationFrame(poll)
|
||||
if (!gamepad) clearControllerState()
|
||||
timer = window.setTimeout(poll, nextDelay())
|
||||
}
|
||||
const onGamepadConnected = () => {
|
||||
gamepadConnectedRef.current = true
|
||||
}
|
||||
const onGamepadDisconnected = () => {
|
||||
gamepadConnectedRef.current = Boolean(firstConnectedGamepad())
|
||||
if (!gamepadConnectedRef.current) clearControllerState()
|
||||
}
|
||||
window.addEventListener('gamepadconnected', onGamepadConnected)
|
||||
window.addEventListener('gamepaddisconnected', onGamepadDisconnected)
|
||||
timer = window.setTimeout(poll, 0)
|
||||
return () => {
|
||||
window.clearTimeout(timer)
|
||||
window.removeEventListener('gamepadconnected', onGamepadConnected)
|
||||
window.removeEventListener('gamepaddisconnected', onGamepadDisconnected)
|
||||
}
|
||||
frame = window.requestAnimationFrame(poll)
|
||||
return () => window.cancelAnimationFrame(frame)
|
||||
}, [assignBinding, dispatchControllerToken])
|
||||
|
||||
const contextValue = useMemo<InputContextValue>(() => ({
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const bundledCatalogHash = '07506c52bab428c439f09a9b82e39e6eff2b243fb972d664da48852059bcd937'
|
||||
@@ -1437,6 +1437,22 @@
|
||||
"setName": null
|
||||
},
|
||||
"components": [
|
||||
{
|
||||
"item": {
|
||||
"id": 383002,
|
||||
"slug": "bulldrome-boss-coin-diff-2-ilvl-10",
|
||||
"name": "Green Bulldrome Coin",
|
||||
"slot": "component",
|
||||
"rarity": "uncommon",
|
||||
"itemLevel": 10,
|
||||
"healingPower": 0,
|
||||
"maxResourceBonus": 0,
|
||||
"glyph": "$",
|
||||
"description": "A boss coin from Bulldrome used for item level 10 crafting."
|
||||
},
|
||||
"quantity": 5,
|
||||
"owned": 0
|
||||
},
|
||||
{
|
||||
"item": {
|
||||
"id": 683002,
|
||||
@@ -1450,7 +1466,7 @@
|
||||
"glyph": "$",
|
||||
"description": "A boss coin from Tigrex used for item level 10 crafting."
|
||||
},
|
||||
"quantity": 10,
|
||||
"quantity": 5,
|
||||
"owned": 0
|
||||
}
|
||||
],
|
||||
@@ -1477,6 +1493,22 @@
|
||||
"setName": null
|
||||
},
|
||||
"components": [
|
||||
{
|
||||
"item": {
|
||||
"id": 383002,
|
||||
"slug": "bulldrome-boss-coin-diff-2-ilvl-10",
|
||||
"name": "Green Bulldrome Coin",
|
||||
"slot": "component",
|
||||
"rarity": "uncommon",
|
||||
"itemLevel": 10,
|
||||
"healingPower": 0,
|
||||
"maxResourceBonus": 0,
|
||||
"glyph": "$",
|
||||
"description": "A boss coin from Bulldrome used for item level 10 crafting."
|
||||
},
|
||||
"quantity": 5,
|
||||
"owned": 0
|
||||
},
|
||||
{
|
||||
"item": {
|
||||
"id": 683002,
|
||||
@@ -1490,7 +1522,7 @@
|
||||
"glyph": "$",
|
||||
"description": "A boss coin from Tigrex used for item level 10 crafting."
|
||||
},
|
||||
"quantity": 10,
|
||||
"quantity": 5,
|
||||
"owned": 0
|
||||
}
|
||||
],
|
||||
@@ -1517,6 +1549,22 @@
|
||||
"setName": null
|
||||
},
|
||||
"components": [
|
||||
{
|
||||
"item": {
|
||||
"id": 383002,
|
||||
"slug": "bulldrome-boss-coin-diff-2-ilvl-10",
|
||||
"name": "Green Bulldrome Coin",
|
||||
"slot": "component",
|
||||
"rarity": "uncommon",
|
||||
"itemLevel": 10,
|
||||
"healingPower": 0,
|
||||
"maxResourceBonus": 0,
|
||||
"glyph": "$",
|
||||
"description": "A boss coin from Bulldrome used for item level 10 crafting."
|
||||
},
|
||||
"quantity": 5,
|
||||
"owned": 0
|
||||
},
|
||||
{
|
||||
"item": {
|
||||
"id": 683002,
|
||||
@@ -1530,7 +1578,7 @@
|
||||
"glyph": "$",
|
||||
"description": "A boss coin from Tigrex used for item level 10 crafting."
|
||||
},
|
||||
"quantity": 10,
|
||||
"quantity": 5,
|
||||
"owned": 0
|
||||
}
|
||||
],
|
||||
|
||||
+2
-1
@@ -349,7 +349,8 @@ export async function completeRoguelike(
|
||||
durationSeconds: number,
|
||||
options?: {
|
||||
bossesCleared?: number
|
||||
experienceMode?: 'default' | 'pvp-boss-quarter-level'
|
||||
experienceMode?: 'default' | 'pvp-boss-quarter-level' | 'pvp-fight-twelfth-level' | 'pvp-match-win-half-level' | 'pvp-stadium-round-win-quarter-level' | 'pvp-stadium-round-loss-tenth-level' | 'pvp-stadium-match-half-level'
|
||||
fightsCleared?: number
|
||||
lootSourceEncounterId?: number
|
||||
roguelikeStage?: number
|
||||
},
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
publishPvpMatchState,
|
||||
requestPvpRematch,
|
||||
type PvpMatchSnapshot,
|
||||
type PvpMatchSide,
|
||||
type PvpMatchStatus,
|
||||
type PvpRematchResponse,
|
||||
} from './pvpRoguelike'
|
||||
|
||||
export type LiveMatchProgressPayload<TSideState> = {
|
||||
state: TSideState
|
||||
status: PvpMatchStatus
|
||||
stage: number
|
||||
encounterIndex: number
|
||||
encountersCleared: number
|
||||
enemyHealth: number
|
||||
alive: boolean
|
||||
elapsedTicks: number
|
||||
}
|
||||
|
||||
type StartLiveMatchSyncOptions<TSideState> = {
|
||||
matchId: string
|
||||
intervalMs?: number
|
||||
getPayload: () => LiveMatchProgressPayload<TSideState>
|
||||
onSnapshot: (snapshot: PvpMatchSnapshot<TSideState>) => void
|
||||
onError?: (reason: unknown) => void
|
||||
}
|
||||
|
||||
type RequestLiveRematchOptions<TSideState> = {
|
||||
matchId: string
|
||||
maxPendingAttempts?: number
|
||||
maxFailureAttempts?: number
|
||||
pendingDelayMs?: number
|
||||
failureDelayMs?: number
|
||||
onMatched: (result: Required<Pick<PvpRematchResponse<TSideState>, 'match' | 'side'>>) => void
|
||||
onExpired: () => void
|
||||
onFailure: (reason: unknown) => void
|
||||
}
|
||||
|
||||
type LivePvpMatchBase = {
|
||||
id: string
|
||||
opponentName: string
|
||||
}
|
||||
|
||||
type UsePvpLiveMatchSyncOptions<TSideState, TLiveMatch extends LivePvpMatchBase> = {
|
||||
liveMatch: TLiveMatch | null
|
||||
syncEnabled: boolean
|
||||
getPayload: (liveMatch: TLiveMatch) => LiveMatchProgressPayload<TSideState>
|
||||
onSnapshot: (snapshot: PvpMatchSnapshot<TSideState>, liveMatch: TLiveMatch) => void
|
||||
onError?: (reason: unknown) => void
|
||||
startLiveMatch: (match: PvpMatchSnapshot<TSideState>, side: PvpMatchSide, message?: string) => void
|
||||
}
|
||||
|
||||
type RematchState = {
|
||||
matchId: string | null
|
||||
requested: boolean
|
||||
message: string
|
||||
}
|
||||
|
||||
/**
|
||||
* React wrapper for live PvP state sync and rematch UX. Mode screens provide
|
||||
* payload and snapshot rules; shared polling and rematch state live here.
|
||||
*/
|
||||
export function usePvpLiveMatchSync<TSideState, TLiveMatch extends LivePvpMatchBase>({
|
||||
liveMatch,
|
||||
syncEnabled,
|
||||
getPayload,
|
||||
onSnapshot,
|
||||
onError,
|
||||
startLiveMatch,
|
||||
}: UsePvpLiveMatchSyncOptions<TSideState, TLiveMatch>) {
|
||||
const [rematchState, setRematchState] = useState<RematchState>({
|
||||
matchId: null,
|
||||
requested: false,
|
||||
message: '',
|
||||
})
|
||||
const getPayloadRef = useRef(getPayload)
|
||||
const onSnapshotRef = useRef(onSnapshot)
|
||||
const onErrorRef = useRef(onError)
|
||||
const startLiveMatchRef = useRef(startLiveMatch)
|
||||
const liveMatchId = liveMatch?.id
|
||||
|
||||
useEffect(() => {
|
||||
getPayloadRef.current = getPayload
|
||||
onSnapshotRef.current = onSnapshot
|
||||
onErrorRef.current = onError
|
||||
startLiveMatchRef.current = startLiveMatch
|
||||
}, [getPayload, onError, onSnapshot, startLiveMatch])
|
||||
|
||||
useEffect(() => {
|
||||
if (!liveMatch || !syncEnabled) return undefined
|
||||
return startLiveMatchSync<TSideState>({
|
||||
matchId: liveMatch.id,
|
||||
getPayload: () => getPayloadRef.current(liveMatch),
|
||||
onSnapshot: (snapshot) => onSnapshotRef.current(snapshot, liveMatch),
|
||||
onError: (reason) => onErrorRef.current?.(reason),
|
||||
})
|
||||
}, [liveMatch, syncEnabled])
|
||||
|
||||
const handleRematch = useCallback(() => {
|
||||
if (!liveMatch || (rematchState.matchId === liveMatch.id && rematchState.requested)) return undefined
|
||||
const { id, opponentName } = liveMatch
|
||||
setRematchState({
|
||||
matchId: id,
|
||||
requested: true,
|
||||
message: `Waiting for ${opponentName} to rematch...`,
|
||||
})
|
||||
return requestLiveRematch<TSideState>({
|
||||
matchId: id,
|
||||
onMatched: (result) => {
|
||||
startLiveMatchRef.current(result.match, result.side, `Rematch against ${opponentName} begins.`)
|
||||
},
|
||||
onExpired: () => {
|
||||
setRematchState({ matchId: id, requested: false, message: 'Rematch expired.' })
|
||||
},
|
||||
onFailure: (reason) => {
|
||||
setRematchState({
|
||||
matchId: id,
|
||||
requested: false,
|
||||
message: reason instanceof Error ? reason.message : 'Unable to request rematch.',
|
||||
})
|
||||
},
|
||||
})
|
||||
}, [liveMatch, rematchState.matchId, rematchState.requested])
|
||||
|
||||
const currentRematchState = rematchState.matchId === liveMatchId
|
||||
? rematchState
|
||||
: { matchId: liveMatchId ?? null, requested: false, message: '' }
|
||||
|
||||
return {
|
||||
rematchRequested: currentRematchState.requested,
|
||||
rematchMessage: currentRematchState.message,
|
||||
handleRematch,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Polls the backend rematch endpoint until both players accept, the request
|
||||
* expires, or repeated network failures make the rematch unavailable.
|
||||
*/
|
||||
export function requestLiveRematch<TSideState>({
|
||||
matchId,
|
||||
maxPendingAttempts = 180,
|
||||
maxFailureAttempts = 10,
|
||||
pendingDelayMs = 700,
|
||||
failureDelayMs = 900,
|
||||
onMatched,
|
||||
onExpired,
|
||||
onFailure,
|
||||
}: RequestLiveRematchOptions<TSideState>) {
|
||||
let cancelled = false
|
||||
let attempts = 0
|
||||
|
||||
const schedule = (callback: () => void, delay: number) => {
|
||||
window.setTimeout(callback, delay)
|
||||
}
|
||||
|
||||
const handleResponse = (result: PvpRematchResponse<TSideState>) => {
|
||||
if (cancelled) return
|
||||
if (result.status === 'matched' && result.match && result.side) {
|
||||
onMatched({ match: result.match, side: result.side })
|
||||
return
|
||||
}
|
||||
attempts += 1
|
||||
if (attempts >= maxPendingAttempts) {
|
||||
onExpired()
|
||||
return
|
||||
}
|
||||
schedule(pollRematch, pendingDelayMs)
|
||||
}
|
||||
|
||||
const pollRematch = () => {
|
||||
requestPvpRematch<TSideState>(matchId)
|
||||
.then(handleResponse)
|
||||
.catch((reason: unknown) => {
|
||||
if (cancelled) return
|
||||
attempts += 1
|
||||
if (attempts >= maxFailureAttempts) {
|
||||
onFailure(reason)
|
||||
return
|
||||
}
|
||||
schedule(pollRematch, failureDelayMs)
|
||||
})
|
||||
}
|
||||
|
||||
pollRematch()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes local live-match progress and polls opponent state on a fixed
|
||||
* interval. Mode-specific snapshot handling stays in the caller.
|
||||
*/
|
||||
export function startLiveMatchSync<TSideState>({
|
||||
matchId,
|
||||
intervalMs = 700,
|
||||
getPayload,
|
||||
onSnapshot,
|
||||
onError = () => undefined,
|
||||
}: StartLiveMatchSyncOptions<TSideState>) {
|
||||
let stopped = false
|
||||
const syncMatch = () => {
|
||||
publishPvpMatchState<TSideState>(matchId, getPayload())
|
||||
.then((snapshot) => {
|
||||
if (!stopped) onSnapshot(snapshot)
|
||||
})
|
||||
.catch((reason: unknown) => {
|
||||
if (!stopped) onError(reason)
|
||||
})
|
||||
}
|
||||
|
||||
syncMatch()
|
||||
const timer = window.setInterval(syncMatch, intervalMs)
|
||||
return () => {
|
||||
stopped = true
|
||||
window.clearInterval(timer)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import {
|
||||
cancelPvpQueue,
|
||||
checkPvpQueue,
|
||||
joinPvpQueue,
|
||||
randomCpuDifficulty,
|
||||
type CpuDifficulty,
|
||||
type PvpContentType,
|
||||
type PvpMatchSnapshot,
|
||||
type PvpMatchSide,
|
||||
} from './pvpRoguelike'
|
||||
import type { GameMode } from './gameRepository'
|
||||
|
||||
export function startPvpQueueWithCpuFallback<TSideState>({
|
||||
contentType,
|
||||
startStage,
|
||||
gameMode,
|
||||
liveMatchActive,
|
||||
onSearching,
|
||||
onCpuMatch,
|
||||
onLiveMatch,
|
||||
messages,
|
||||
}: {
|
||||
contentType: PvpContentType
|
||||
startStage: number
|
||||
gameMode: GameMode
|
||||
liveMatchActive: () => boolean
|
||||
onSearching: (message: string) => void
|
||||
onCpuMatch: (difficulty: CpuDifficulty, message: string) => void
|
||||
onLiveMatch: (match: PvpMatchSnapshot<TSideState>, side: PvpMatchSide, message: string) => void
|
||||
messages: {
|
||||
offline: (difficulty: CpuDifficulty) => string
|
||||
searching: string
|
||||
notFound: (difficulty: CpuDifficulty) => string
|
||||
unavailable: (difficulty: CpuDifficulty) => string
|
||||
liveFound: (match: PvpMatchSnapshot<TSideState>, side: PvpMatchSide) => string
|
||||
}
|
||||
}) {
|
||||
if (gameMode === 'offline') {
|
||||
const difficulty = randomCpuDifficulty()
|
||||
const timer = window.setTimeout(() => {
|
||||
onCpuMatch(difficulty, messages.offline(difficulty))
|
||||
}, 500)
|
||||
return () => window.clearTimeout(timer)
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
let ticketId = ''
|
||||
let pollTimer: number | undefined
|
||||
onSearching(messages.searching)
|
||||
|
||||
const beginLiveMatch = (match: PvpMatchSnapshot<TSideState>, side: PvpMatchSide) => {
|
||||
if (cancelled) return
|
||||
onLiveMatch(match, side, messages.liveFound(match, side))
|
||||
}
|
||||
|
||||
const fallbackTimer = window.setTimeout(() => {
|
||||
if (cancelled || liveMatchActive()) return
|
||||
cancelled = true
|
||||
if (ticketId) cancelPvpQueue(ticketId).catch(() => undefined)
|
||||
const difficulty = randomCpuDifficulty()
|
||||
onCpuMatch(difficulty, messages.notFound(difficulty))
|
||||
}, 5000)
|
||||
|
||||
const pollQueue = () => {
|
||||
if (!ticketId || cancelled) return
|
||||
checkPvpQueue<TSideState>(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<TSideState>(contentType, startStage)
|
||||
.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 difficulty = randomCpuDifficulty()
|
||||
onCpuMatch(difficulty, messages.unavailable(difficulty))
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
window.clearTimeout(fallbackTimer)
|
||||
if (pollTimer) window.clearTimeout(pollTimer)
|
||||
if (ticketId && !liveMatchActive()) cancelPvpQueue(ticketId).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
+117
-1
@@ -1,5 +1,59 @@
|
||||
export type PvpContentType = 'dungeon' | 'raid'
|
||||
import { requestGameApiJson } from './gameRepository'
|
||||
|
||||
export type PvpContentType = 'dungeon' | 'raid' | 'stadium'
|
||||
export type CpuDifficulty = 1 | 2 | 3 | 4 | 5
|
||||
export type PvpMatchSide = 'a' | 'b'
|
||||
export type PvpMatchStatus = 'playing' | 'upgrade-choice' | 'shop' | 'won' | 'lost'
|
||||
|
||||
export type PvpPlayerInfo = {
|
||||
side: PvpMatchSide
|
||||
accountId: number
|
||||
characterId: number
|
||||
characterName: string
|
||||
className: string
|
||||
}
|
||||
|
||||
export type PvpUpgradeChoicePayload = {
|
||||
encounterIndex: number
|
||||
buffId: string
|
||||
debuffId: string
|
||||
purchases?: string[]
|
||||
shopReady?: boolean
|
||||
}
|
||||
|
||||
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, PvpMatchStatus>>
|
||||
progress: Partial<Record<PvpMatchSide, {
|
||||
stage: number
|
||||
encounterIndex: number
|
||||
encountersCleared: number
|
||||
enemyHealth: number
|
||||
alive: boolean
|
||||
elapsedTicks: number
|
||||
}>>
|
||||
upgradeChoices: Partial<Record<PvpMatchSide, Record<string, PvpUpgradeChoicePayload>>>
|
||||
rematchRequests?: Partial<Record<PvpMatchSide, boolean>>
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
export type PvpQueueResponse<TSideState = unknown> = {
|
||||
ticketId: string
|
||||
status: 'waiting' | 'matched'
|
||||
match?: PvpMatchSnapshot<TSideState>
|
||||
side?: PvpMatchSide
|
||||
}
|
||||
|
||||
export type PvpRematchResponse<TSideState = unknown> = {
|
||||
status: 'waiting' | 'matched'
|
||||
match?: PvpMatchSnapshot<TSideState>
|
||||
side?: PvpMatchSide
|
||||
}
|
||||
|
||||
export type CpuPvpLeaderboardEntry = {
|
||||
characterName: string
|
||||
@@ -66,3 +120,65 @@ export function recordPvpRoguelikeCheckpoint(
|
||||
localStorage.setItem(checkpointStorageKey(characterId, contentType), String(next))
|
||||
return next
|
||||
}
|
||||
|
||||
export function joinPvpQueue<TSideState>(
|
||||
contentType: PvpContentType,
|
||||
startStage: number,
|
||||
): Promise<PvpQueueResponse<TSideState>> {
|
||||
return requestGameApiJson('/api/pvp/queue', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ contentType, startStage }),
|
||||
})
|
||||
}
|
||||
|
||||
export function checkPvpQueue<TSideState>(ticketId: string): Promise<PvpQueueResponse<TSideState>> {
|
||||
return requestGameApiJson(`/api/pvp/queue/${encodeURIComponent(ticketId)}`)
|
||||
}
|
||||
|
||||
export function cancelPvpQueue(ticketId: string): Promise<{ ok: true }> {
|
||||
return requestGameApiJson(`/api/pvp/queue/${encodeURIComponent(ticketId)}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
export function publishPvpMatchState<TSideState>(
|
||||
matchId: string,
|
||||
payload: {
|
||||
state: TSideState
|
||||
status: PvpMatchStatus
|
||||
stage: number
|
||||
encounterIndex: number
|
||||
encountersCleared: number
|
||||
enemyHealth: number
|
||||
alive: boolean
|
||||
elapsedTicks: number
|
||||
},
|
||||
): Promise<PvpMatchSnapshot<TSideState>> {
|
||||
return requestGameApiJson(`/api/pvp/matches/${encodeURIComponent(matchId)}/state`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function loadPvpMatch<TSideState>(matchId: string): Promise<PvpMatchSnapshot<TSideState>> {
|
||||
return requestGameApiJson(`/api/pvp/matches/${encodeURIComponent(matchId)}`)
|
||||
}
|
||||
|
||||
export function submitPvpUpgradeChoice(
|
||||
matchId: string,
|
||||
payload: PvpUpgradeChoicePayload,
|
||||
): Promise<PvpMatchSnapshot> {
|
||||
return requestGameApiJson(`/api/pvp/matches/${encodeURIComponent(matchId)}/upgrade-choice`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function requestPvpRematch<TSideState>(matchId: string): Promise<PvpRematchResponse<TSideState>> {
|
||||
return requestGameApiJson(`/api/pvp/matches/${encodeURIComponent(matchId)}/rematch`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
export function experienceForLevel(level: number): number
|
||||
export function catchUpExperienceReward(
|
||||
baseReward: number,
|
||||
currentExperience: number,
|
||||
targetExperience: number,
|
||||
): number
|
||||
export function scaledPvpBossExperience(
|
||||
startingExperience: number,
|
||||
startingLevel: number,
|
||||
bossesCleared: number,
|
||||
maxLevel: number,
|
||||
targetLevel?: number,
|
||||
levelExperience?: (level: number) => number,
|
||||
): { experience: number; level: number }
|
||||
export function scaledPvpFightExperience(
|
||||
startingExperience: number,
|
||||
startingLevel: number,
|
||||
fightsCleared: number,
|
||||
maxLevel: number,
|
||||
targetLevel?: number,
|
||||
levelExperience?: (level: number) => number,
|
||||
): { experience: number; level: number }
|
||||
export function scaledCurrentLevelExperience(
|
||||
startingExperience: number,
|
||||
startingLevel: number,
|
||||
maxLevel: number,
|
||||
rate: number,
|
||||
levelExperience?: (level: number) => number,
|
||||
): { experience: number; level: number }
|
||||
export function coinDropQuantity(): number
|
||||
export function roguelikeCoinItemLevel(stage: number): number
|
||||
@@ -0,0 +1,95 @@
|
||||
export function experienceForLevel(level) {
|
||||
return 100 * (level - 1) ** 2
|
||||
}
|
||||
|
||||
export function catchUpExperienceReward(baseReward, currentExperience, targetExperience) {
|
||||
const gap = Math.max(0, targetExperience - currentExperience)
|
||||
if (gap <= 0) return baseReward
|
||||
const doubledBase = Math.min(baseReward, Math.ceil(gap / 2))
|
||||
return doubledBase * 2 + (baseReward - doubledBase)
|
||||
}
|
||||
|
||||
export function scaledPvpBossExperience(
|
||||
startingExperience,
|
||||
startingLevel,
|
||||
bossesCleared,
|
||||
maxLevel,
|
||||
targetLevel = startingLevel,
|
||||
levelExperience = experienceForLevel,
|
||||
) {
|
||||
let experience = startingExperience
|
||||
let level = startingLevel
|
||||
const maxExperience = levelExperience(maxLevel)
|
||||
for (let bossIndex = 0; bossIndex < bossesCleared && experience < maxExperience; bossIndex += 1) {
|
||||
const currentLevelFloor = levelExperience(level)
|
||||
const nextLevelExperience = level >= maxLevel
|
||||
? maxExperience
|
||||
: levelExperience(level + 1)
|
||||
const levelBand = Math.max(1, nextLevelExperience - currentLevelFloor)
|
||||
const rewardRate = targetLevel > level ? 0.5 : 0.25
|
||||
experience = Math.min(maxExperience, experience + Math.round(levelBand * rewardRate))
|
||||
while (level < maxLevel && levelExperience(level + 1) <= experience) {
|
||||
level += 1
|
||||
}
|
||||
}
|
||||
return { experience, level }
|
||||
}
|
||||
|
||||
export function scaledPvpFightExperience(
|
||||
startingExperience,
|
||||
startingLevel,
|
||||
fightsCleared,
|
||||
maxLevel,
|
||||
targetLevel = startingLevel,
|
||||
levelExperience = experienceForLevel,
|
||||
) {
|
||||
let experience = startingExperience
|
||||
let level = startingLevel
|
||||
const maxExperience = levelExperience(maxLevel)
|
||||
for (let fightIndex = 0; fightIndex < fightsCleared && experience < maxExperience; fightIndex += 1) {
|
||||
const currentLevelFloor = levelExperience(level)
|
||||
const nextLevelExperience = level >= maxLevel
|
||||
? maxExperience
|
||||
: levelExperience(level + 1)
|
||||
const levelBand = Math.max(1, nextLevelExperience - currentLevelFloor)
|
||||
const rewardRate = targetLevel > level ? 1 / 6 : 1 / 12
|
||||
experience = Math.min(maxExperience, experience + Math.round(levelBand * rewardRate))
|
||||
while (level < maxLevel && levelExperience(level + 1) <= experience) {
|
||||
level += 1
|
||||
}
|
||||
}
|
||||
return { experience, level }
|
||||
}
|
||||
|
||||
export function scaledCurrentLevelExperience(
|
||||
startingExperience,
|
||||
startingLevel,
|
||||
maxLevel,
|
||||
rate,
|
||||
levelExperience = experienceForLevel,
|
||||
) {
|
||||
let experience = startingExperience
|
||||
let level = startingLevel
|
||||
const maxExperience = levelExperience(maxLevel)
|
||||
const currentLevelFloor = levelExperience(level)
|
||||
const nextLevelExperience = level >= maxLevel
|
||||
? maxExperience
|
||||
: levelExperience(level + 1)
|
||||
const levelBand = Math.max(1, nextLevelExperience - currentLevelFloor)
|
||||
experience = Math.min(maxExperience, experience + Math.round(levelBand * rate))
|
||||
while (level < maxLevel && levelExperience(level + 1) <= experience) {
|
||||
level += 1
|
||||
}
|
||||
return { experience, level }
|
||||
}
|
||||
|
||||
export function coinDropQuantity() {
|
||||
const roll = Math.random()
|
||||
if (roll < 0.15) return 3
|
||||
if (roll < 0.5) return 2
|
||||
return 1
|
||||
}
|
||||
|
||||
export function roguelikeCoinItemLevel(stage) {
|
||||
return Math.min(25, 5 + Math.max(0, Math.floor(stage / 5)) * 5)
|
||||
}
|
||||
Reference in New Issue
Block a user