Implement Finale Echoing (Permanent)

- Sweeps and quests this game mode are not implemented yet
This commit is contained in:
Melledy
2026-03-15 02:33:40 -07:00
parent e0a6659c38
commit f007b6624f
23 changed files with 989 additions and 7 deletions
@@ -11,6 +11,7 @@ import emu.nebula.Nebula;
import emu.nebula.game.activity.ActivityModule;
import emu.nebula.game.ban.BanModule;
import emu.nebula.game.gacha.GachaModule;
import emu.nebula.game.jointdrill.JointDrillModule;
import emu.nebula.game.player.PlayerModule;
import emu.nebula.game.scoreboss.ScoreBossModule;
import emu.nebula.game.tutorial.TutorialModule;
@@ -31,6 +32,7 @@ public class GameContext implements Runnable {
private final TutorialModule tutorialModule;
private final ActivityModule activityModule;
private final ScoreBossModule scoreBossModule;
private final JointDrillModule jointDrillModule;
private final BanModule banModule;
// Game loop
@@ -50,6 +52,7 @@ public class GameContext implements Runnable {
this.tutorialModule = new TutorialModule(this);
this.activityModule = new ActivityModule(this);
this.scoreBossModule = new ScoreBossModule(this);
this.jointDrillModule = new JointDrillModule(this);
this.banModule = new BanModule(this);
// Run game loop
@@ -132,6 +132,7 @@ public class ActivityManager extends PlayerManager implements GameDatabaseObject
case LoginReward -> new LoginRewardActivity(this, data);
case TowerDefense -> new TowerDefenseActivity(this, data);
case Trial -> new TrialActivity(this, data);
case JointDrill -> new JointDrillActivity(this, data);
case Levels -> new LevelsActivity(this, data);
case Task -> new TaskActivity(this, data);
case Shop -> new ShopActivity(this, data);
@@ -44,6 +44,9 @@ public class ActivityModule extends GameContextModule {
this.activities.add(1010503);
this.activities.add(1010504);
// ===== Joint Drills (Finale Echoing) =====
this.activities.add(510003);
// ===== Etc Events =====
// Trial activities
@@ -31,6 +31,16 @@ public abstract class GameActivity {
public Player getPlayer() {
return this.getManager().getPlayer();
}
public long getStartTime() {
return 1;
}
public long getEndTime() {
return Integer.MAX_VALUE;
}
// Database
public void save() {
Nebula.getGameDatabase().update(
@@ -52,8 +62,8 @@ public abstract class GameActivity {
public Activity toProto() {
var proto = Activity.newInstance()
.setId(this.getId())
.setStartTime(1)
.setEndTime(Integer.MAX_VALUE);
.setStartTime(this.getStartTime())
.setEndTime(this.getEndTime());
return proto;
}
@@ -0,0 +1,273 @@
package emu.nebula.game.activity.type;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;
import dev.morphia.annotations.Entity;
import emu.nebula.GameConstants;
import emu.nebula.Nebula;
import emu.nebula.data.GameData;
import emu.nebula.data.resources.ActivityDef;
import emu.nebula.data.resources.JointDrill2LevelDef;
import emu.nebula.game.activity.ActivityManager;
import emu.nebula.game.activity.GameActivity;
import emu.nebula.game.jointdrill.JointDrillBuild;
import emu.nebula.game.jointdrill.JointDrillRankEntry;
import emu.nebula.game.jointdrill.JointDrillScore;
import emu.nebula.game.player.PlayerChangeInfo;
import emu.nebula.game.tower.StarTowerBuild;
import emu.nebula.proto.ActivityDetail.ActivityMsg;
import emu.nebula.proto.PublicJointDrill.JointDrillLevel;
import it.unimi.dsi.fastutil.ints.Int2IntMap;
import it.unimi.dsi.fastutil.ints.Int2IntOpenHashMap;
import lombok.Getter;
@Getter
@Entity
public class JointDrillActivity extends GameActivity {
private Int2IntMap passedLevels;
// Current level
private transient JointDrill2LevelDef level;
private transient StarTowerBuild build;
private transient boolean simulate;
private transient List<JointDrillBuild> teams;
// Ranking
private transient boolean checkedDatabase;
private transient JointDrillRankEntry ranking;
@Deprecated // Morphia only
public JointDrillActivity() {
this.teams = new ArrayList<>();
}
public JointDrillActivity(ActivityManager manager, ActivityDef data) {
super(manager, data);
// Give the player starting amount of tickets
int tickets = this.getPlayer().getInventory().getResourceCount(GameConstants.JOINT_DRILL_TICKET_ID);
if (tickets < 3) {
this.getPlayer().getInventory().addItem(GameConstants.JOINT_DRILL_TICKET_ID, 3 - tickets);
}
}
public Int2IntMap getPassedLevels() {
if (this.passedLevels == null) {
this.passedLevels = new Int2IntOpenHashMap();
}
return this.passedLevels;
}
public long getStartTime() {
// Force activity to be always open for 6+ days
return Nebula.getCurrentServerTime() - TimeUnit.DAYS.toSeconds(1);
}
public void reset() {
this.level = null;
this.build = null;
this.teams.clear();
}
// Joint drill
public JointDrillRankEntry getRankEntry() {
if (this.ranking == null && !this.checkedDatabase) {
this.ranking = Nebula.getGameDatabase().getObjectByUid(JointDrillRankEntry.class, this.getPlayer().getUid());
this.checkedDatabase = true;
}
return this.ranking;
}
public synchronized boolean inProgress() {
return this.level != null;
}
public synchronized PlayerChangeInfo apply(int levelId, long buildId, boolean simulate) {
// Get level and record used
var level = GameData.getJointDrill2LevelDataTable().get(levelId);
var build = this.getPlayer().getStarTowerManager().getBuildById(buildId);
// Verify that level and record exists
if (level == null || build == null) {
return null;
}
// Create result
PlayerChangeInfo change = null;
// Consume ticket IF not simulated
if (!simulate) {
// Make sure player has a ticket
if (!getPlayer().getInventory().hasItem(GameConstants.JOINT_DRILL_TICKET_ID, 1)) {
return null;
}
change = getPlayer().getInventory().removeItem(GameConstants.JOINT_DRILL_TICKET_ID, 1);
} else {
change = new PlayerChangeInfo();
}
// Set level and build
this.level = level;
this.build = build;
this.teams.clear();
this.simulate = simulate;
// Failure - No level or record found
return change;
}
public synchronized JointDrillScore settle(int time, int damage, boolean win) {
// Make sure we are currently in progress
if (!this.inProgress()) {
return null;
}
// Create score data
var score = new JointDrillScore();
// Calculate score
if (win) {
score.calculateScore(this.getLevel(), time);
} else {
// TODO calculate partial score
}
// Add joint drill team
if (this.getBuild() != null) {
var team = new JointDrillBuild(this.getBuild(), time, damage);
this.getTeams().add(team);
}
// Only update if we are not simulating a challenge
if (!this.simulate && win) {
// Check if this is our first clear
boolean isFirstClear = this.getPassedLevels().containsKey(this.getLevel().getId());
// Get current score
int currentScore = this.getPassedLevels().get(this.getLevel().getId());
// Update score
if (score.getTotal() >= currentScore) {
// Update passed levels
this.getPassedLevels().put(this.getLevel().getId(), score.getTotal());
// Save activity to database
this.save();
}
// Handle ranking
if (this.getTeams().size() > 0) {
// Get ranking from database
this.getRankEntry();
// Create ranking if its not in the database
if (this.ranking == null) {
this.ranking = new JointDrillRankEntry(this.getPlayer(), this.getId());
}
// Settle ranking and save if we have a higher score
if (score.getTotal() > this.ranking.getScore()) {
this.ranking.settle(this.getPlayer(), this.getTeams(), this.getId(), score.getTotal());
this.ranking.save();
}
}
// Handle rewards
if (isFirstClear) {
score.getRewards().add(this.getLevel().getFirstRewards().generate());
}
score.getRewards().add(this.getLevel().getRewards().generate());
// Add rewards
this.getPlayer().getInventory().addItems(score.getRewards(), score.getChange());
}
// Reset level
this.reset();
// Finished
return score;
}
public synchronized boolean continueDrill(long buildId) {
// Make sure we are currently in progress
if (!this.inProgress()) {
return false;
}
if (this.getTeams().size() >= 3) {
return false;
}
// Get build
var build = this.getPlayer().getStarTowerManager().getBuildById(buildId);
if (build == null) {
return false;
}
// TODO validate build as unique
// Set build
this.build = build;
// Complete
return true;
}
public synchronized JointDrillScore giveup(int time, int damage) {
// Make sure we are currently in progress
if (!this.inProgress() || this.getBuild() == null) {
return null;
}
// Create and calculate score
var score = new JointDrillScore();
// TODO calculate partial score
// Log joint drill team
var team = new JointDrillBuild(this.getBuild(), time, damage);
this.getTeams().add(team);
// Clear build
this.build = null;
// Complete
return score;
}
// Proto
@Override
public void encodeActivityMsg(ActivityMsg msg) {
var proto = msg.getMutableJointDrill();
// Mark
proto.getMutableMeta();
proto.getMutableMode2();
// Add passed levels
if (this.passedLevels != null) {
for (var entry : this.passedLevels.int2IntEntrySet()) {
int levelId = entry.getIntKey();
int score = entry.getIntValue();
var info = JointDrillLevel.newInstance()
.setLevelId(levelId)
.setScore(score);
proto.addPassedLevels(info);
}
}
}
}
@@ -0,0 +1,17 @@
package emu.nebula.game.jointdrill;
import emu.nebula.game.tower.StarTowerBuild;
import lombok.Getter;
@Getter
public class JointDrillBuild {
private StarTowerBuild build;
private int time;
private int damage;
public JointDrillBuild(StarTowerBuild build, int time, int damage) {
this.build = build;
this.time = time;
this.damage = damage;
}
}
@@ -0,0 +1,55 @@
package emu.nebula.game.jointdrill;
import java.util.ArrayList;
import java.util.List;
import emu.nebula.Nebula;
import emu.nebula.game.GameContext;
import emu.nebula.game.GameContextModule;
import emu.nebula.proto.JointDrillRank.JointDrillRankData;
@SuppressWarnings("unused")
public class JointDrillModule extends GameContextModule {
private long lastUpdate;
private long nextUpdate;
private List<JointDrillRankData> ranking;
public JointDrillModule(GameContext context) {
super(context);
this.nextUpdate = -1;
this.ranking = new ArrayList<>();
}
private long getRefreshTime() {
return Nebula.getConfig().getServerOptions().leaderboardRefreshTime * 1000;
}
public synchronized List<JointDrillRankData> getRanking(int activityId) {
if (System.currentTimeMillis() > this.nextUpdate) {
this.updateRanking(activityId);
}
return this.ranking;
}
// Cache ranking so we dont query the database too much
private void updateRanking(int activityId) {
// Clear
this.ranking.clear();
// Get from database
var list = Nebula.getGameDatabase().getSortedObjects(JointDrillRankEntry.class, "activityId", activityId, "score", 50);
for (int i = 0; i < list.size(); i++) {
// Get rank entry and set proto
var entry = list.get(i);
entry.setRank(i + 1);
// Add to ranking
this.ranking.add(entry.toProto());
}
this.nextUpdate = System.currentTimeMillis() + this.getRefreshTime();
this.lastUpdate = Nebula.getCurrentServerTime();
}
}
@@ -0,0 +1,190 @@
package emu.nebula.game.jointdrill;
import java.util.ArrayList;
import java.util.List;
import dev.morphia.annotations.Entity;
import dev.morphia.annotations.Id;
import emu.nebula.database.GameDatabaseObject;
import emu.nebula.game.player.Player;
import emu.nebula.game.tower.StarTowerBuild;
import emu.nebula.game.character.GameCharacter;
import emu.nebula.proto.JointDrillRank.JointDrillRankChar;
import emu.nebula.proto.JointDrillRank.JointDrillRankData;
import emu.nebula.proto.JointDrillRank.JointDrillRankTeam;
import emu.nebula.proto.Public.HonorInfo;
import it.unimi.dsi.fastutil.ints.IntOpenHashSet;
import it.unimi.dsi.fastutil.ints.IntSet;
import lombok.Getter;
import lombok.Setter;
@Getter
@Entity(value = "joint_drill_rank", useDiscriminator = false)
public class JointDrillRankEntry implements GameDatabaseObject {
@Id
private int playerUid;
private String name;
private int level;
private int headIcon;
private int titlePrefix;
private int titleSuffix;
private int[] honor;
private int score;
private IntSet claimedRewards;
private int activityId;
private List<JointDrillTeamEntry> teams;
@Setter
private transient int rank;
@Deprecated // Morphia only
public JointDrillRankEntry() {
this.rank = 999;
}
public JointDrillRankEntry(Player player, int activityId) {
this.playerUid = player.getUid();
this.activityId = activityId;
this.teams = new ArrayList<>();
}
public IntSet getClaimedRewards() {
if (this.claimedRewards == null) {
this.claimedRewards = new IntOpenHashSet();
}
return this.claimedRewards;
}
public void update(Player player) {
this.name = player.getName();
this.level = player.getLevel();
this.headIcon = player.getHeadIcon();
this.titlePrefix = player.getTitlePrefix();
this.titleSuffix = player.getTitleSuffix();
this.honor = player.getHonor();
}
public void settle(Player player, List<JointDrillBuild> builds, int activityId, int score) {
// Update player data
this.update(player);
// Reset score entry if activity id doesn't match
if (this.activityId != activityId) {
this.activityId = activityId;
this.reset();
}
// Add teams
for (var build : builds) {
var team = new JointDrillTeamEntry(player, build.getBuild(), build.getTime(), build.getDamage());
this.getTeams().add(team);
}
// Calculate score
this.score = score;
}
private void reset() {
this.score = 0;
this.getClaimedRewards().clear();
this.getTeams().clear();
}
// Proto
public JointDrillRankData toProto() {
var proto = JointDrillRankData.newInstance()
.setId(this.getPlayerUid())
.setNickName(this.getName())
.setWorldClass(this.getLevel())
.setHeadIcon(this.getHeadIcon())
.setScore(this.getScore())
.setTitlePrefix(this.getTitlePrefix())
.setTitleSuffix(this.getTitleSuffix())
.setRank(this.getRank());
for (int id : this.getHonor()) {
proto.addHonors(HonorInfo.newInstance().setId(id));
}
for (var team : this.getTeams()) {
proto.addTeams(team.toProto());
}
return proto;
}
// Extra classes
@Getter
@Entity(useDiscriminator = false)
public static class JointDrillTeamEntry {
private int buildId;
private int buildScore;
private int damage;
private int time;
private List<JointDrillCharEntry> characters;
@Deprecated // Morphia only
public JointDrillTeamEntry() {
}
public JointDrillTeamEntry(Player player, StarTowerBuild build, int time, int damage) {
this.buildId = build.getUid();
this.buildScore = build.getScore();
this.time = time;
this.damage = damage;
this.characters = new ArrayList<>();
for (var charId : build.getCharIds()) {
var character = player.getCharacters().getCharacterById(charId);
if (character != null) {
this.getCharacters().add(new JointDrillCharEntry(character));
}
}
}
public JointDrillRankTeam toProto() {
var proto = JointDrillRankTeam.newInstance()
.setBuildScore(this.getBuildScore())
.setDamage(this.getDamage())
.setTime(this.getTime());
for (var c : this.getCharacters()) {
proto.addChars(c.toProto());
}
return proto;
}
}
@Getter
@Entity(useDiscriminator = false)
public static class JointDrillCharEntry {
private int id;
private int level;
@Deprecated // Morphia only
public JointDrillCharEntry() {
}
public JointDrillCharEntry(GameCharacter character) {
this.id = character.getCharId();
this.level = character.getLevel();
}
public JointDrillRankChar toProto() {
var proto = JointDrillRankChar.newInstance()
.setId(this.getId())
.setLevel(this.getLevel());
return proto;
}
}
}
@@ -0,0 +1,40 @@
package emu.nebula.game.jointdrill;
import emu.nebula.data.resources.JointDrill2LevelDef;
import emu.nebula.game.inventory.ItemParamMap;
import emu.nebula.game.player.PlayerChangeInfo;
import lombok.Getter;
@Getter
public class JointDrillScore {
// Scores
private int fight;
private int hp;
private int difficulty;
private int total;
// Items
private PlayerChangeInfo change;
private ItemParamMap rewards;
public JointDrillScore() {
this.change = new PlayerChangeInfo();
this.rewards = new ItemParamMap();
}
public void calculateScore(JointDrill2LevelDef level, int time) {
// Clamp time
time = Math.max(time, 0);
// Get time penalty
int timePenalty = (int) (level.getScorePerSec() * (time / 1000D));
// Calculate scores
this.difficulty = level.getLevelScore();
this.hp = level.getBaseHpScore();
this.fight = Math.max(level.getTimeScore() - timePenalty, 0);
// Calculate total score
this.total = this.difficulty + this.hp + this.fight;
}
}
@@ -707,6 +707,12 @@ public class Player implements GameDatabaseObject {
this.getQuestManager().resetDailyQuests(resetWeekly);
this.getBattlePassManager().getBattlePass().resetDailyQuests(resetWeekly);
// Add daily joint drill tickets
int tickets = this.getInventory().getResourceCount(GameConstants.JOINT_DRILL_TICKET_ID);
if (tickets < 3) {
this.getInventory().addItem(GameConstants.JOINT_DRILL_TICKET_ID, 3 - tickets);
}
// Check to reset weeklies
if (resetWeekly) {
// Add weekly boss entry item