grant achievement progress and roll exp into levels
two separate breakages behind "no achievement, no level up", both checked against the client in IDA. level up: LogicClientAvatar::xpGainHelper adds a gain to exp_points - which is progress WITHIN the level, not a running total - then rolls it into levels: while exp_points >= ExpToNextLevel(level) it subtracts that threshold and increments the level, calling levelUp for the deck slots and diamond reward. our add_exp only grew exp_points, so the bar filled past full and the level never moved. it now rolls over the same way, against the exp_levels table. achievement: the client only offers a claim when progress >= ActionCount (LogicClientAvatar::isAchievementCompleted reads commodity 2), and the claim command re-reads that same commodity - and build_avatar populated none of it, so every claim came back "not completed". build_avatar now serves real progress: findcard from the count of distinct cards owned, reacharena from the arena reached. donate / jointeam / watchtv need social features we do not have and stay at zero. tests cover both and fail without the fixes: 25 exp reaches level 2 with 5 carried, and a completed findcard tier claims once, grants exp, and is rejected the second time.
This commit is contained in:
parent
dfdcc3072a
commit
0451fcdf43
6 changed files with 158 additions and 9 deletions
|
|
@ -5,6 +5,9 @@ use logic::model::{
|
||||||
use titan::LogicLong;
|
use titan::LogicLong;
|
||||||
use logic::data::RESOURCE_GOLD;
|
use logic::data::RESOURCE_GOLD;
|
||||||
use logic::{table, LogicDataRef};
|
use logic::{table, LogicDataRef};
|
||||||
|
use logic::data::LogicDataTables;
|
||||||
|
use logic::commands::COMMODITY_ACHIEVEMENT_PROGRESS;
|
||||||
|
use std::sync::Arc;
|
||||||
use crate::catalog::GOLD_RESOURCE_FALLBACK_INSTANCE;
|
use crate::catalog::GOLD_RESOURCE_FALLBACK_INSTANCE;
|
||||||
use crate::store::{OwnedCard, PlayerProfile, StoredChest};
|
use crate::store::{OwnedCard, PlayerProfile, StoredChest};
|
||||||
pub const COMMODITY_RESOURCES: usize = 0;
|
pub const COMMODITY_RESOURCES: usize = 0;
|
||||||
|
|
@ -110,6 +113,42 @@ fn gold_resource(profile: &PlayerProfile) -> LogicDataRef {
|
||||||
}
|
}
|
||||||
LogicDataRef::of(table::RESOURCES, GOLD_RESOURCE_FALLBACK_INSTANCE)
|
LogicDataRef::of(table::RESOURCES, GOLD_RESOURCE_FALLBACK_INSTANCE)
|
||||||
}
|
}
|
||||||
|
fn achievement_progress(profile: &PlayerProfile) -> Vec<LogicDataSlot> {
|
||||||
|
let tables = LogicDataTables::instance();
|
||||||
|
let Some(achievements) = tables.table(table::ACHIEVEMENTS) else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
let distinct_cards = {
|
||||||
|
let mut seen: Vec<titan::GlobalId> = Vec::new();
|
||||||
|
for card in profile.deck.iter().chain(profile.collection.iter()) {
|
||||||
|
if let Some(id) = card.card.global_id() {
|
||||||
|
if !seen.contains(&id) {
|
||||||
|
seen.push(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
seen.len() as i32
|
||||||
|
};
|
||||||
|
let arenas_reached = profile.arena.instance_id().unwrap_or(0) + 1;
|
||||||
|
let mut slots = Vec::new();
|
||||||
|
for index in 0..achievements.count() {
|
||||||
|
let Some(row) = achievements.get_at(index) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let progress = match row.string("Action") {
|
||||||
|
"findcard" => distinct_cards,
|
||||||
|
"reacharena" => arenas_reached,
|
||||||
|
_ => 0,
|
||||||
|
};
|
||||||
|
if progress > 0 {
|
||||||
|
slots.push(LogicDataSlot::new(
|
||||||
|
LogicDataRef::Resolved(Arc::clone(row)),
|
||||||
|
progress,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
slots
|
||||||
|
}
|
||||||
pub fn build_avatar(profile: &PlayerProfile) -> LogicClientAvatar {
|
pub fn build_avatar(profile: &PlayerProfile) -> LogicClientAvatar {
|
||||||
let account = LogicLong::new(profile.account.high, profile.account.low);
|
let account = LogicLong::new(profile.account.high, profile.account.low);
|
||||||
let mut commodities = LogicCommodityStore::default();
|
let mut commodities = LogicCommodityStore::default();
|
||||||
|
|
@ -117,6 +156,7 @@ pub fn build_avatar(profile: &PlayerProfile) -> LogicClientAvatar {
|
||||||
COMMODITY_RESOURCES,
|
COMMODITY_RESOURCES,
|
||||||
vec![LogicDataSlot::new(gold_resource(profile), profile.gold)],
|
vec![LogicDataSlot::new(gold_resource(profile), profile.gold)],
|
||||||
);
|
);
|
||||||
|
commodities.set(COMMODITY_ACHIEVEMENT_PROGRESS, achievement_progress(profile));
|
||||||
LogicClientAvatar {
|
LogicClientAvatar {
|
||||||
avatar_id: account,
|
avatar_id: account,
|
||||||
account_id: account,
|
account_id: account,
|
||||||
|
|
|
||||||
79
crates/game-service/tests/achievements_and_levels.rs
Normal file
79
crates/game-service/tests/achievements_and_levels.rs
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
use std::path::Path;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use game_service::home::build_avatar;
|
||||||
|
use logic::commands::COMMODITY_ACHIEVEMENT_PROGRESS;
|
||||||
|
use logic::{CommandOutcome, LogicClaimAchievementRewardCommand, LogicCommandHeader};
|
||||||
|
use logic::data::{table, LogicDataRef, LogicDataTables};
|
||||||
|
use logic::home::LogicHomeMode;
|
||||||
|
use logic::model::LogicClientHome;
|
||||||
|
fn tables() {
|
||||||
|
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets");
|
||||||
|
if let Ok(t) = LogicDataTables::load_from_dir(&root) {
|
||||||
|
LogicDataTables::install(Arc::new(t));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn adding_exp_rolls_over_into_levels() {
|
||||||
|
tables();
|
||||||
|
let mut home = LogicHomeMode::new(LogicClientHome::default(), Default::default(), 0);
|
||||||
|
home.avatar_mut().exp_level = 1;
|
||||||
|
home.avatar_mut().exp_points = 0;
|
||||||
|
home.add_exp(25);
|
||||||
|
assert_eq!(home.avatar().exp_level, 2, "25 exp should reach level 2");
|
||||||
|
assert_eq!(home.avatar().exp_points, 5, "5 exp should carry into level 2");
|
||||||
|
home.add_exp(50);
|
||||||
|
assert_eq!(home.avatar().exp_level, 3, "another 50 should reach level 3");
|
||||||
|
assert_eq!(home.avatar().exp_points, 5);
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn a_completed_findcard_achievement_can_be_claimed() {
|
||||||
|
tables();
|
||||||
|
let tabs = LogicDataTables::instance();
|
||||||
|
let achievements = tabs.table(table::ACHIEVEMENTS).unwrap();
|
||||||
|
let (name, required) = (0..achievements.count())
|
||||||
|
.filter_map(|i| achievements.get_at(i))
|
||||||
|
.find(|row| row.string("Action") == "findcard")
|
||||||
|
.map(|row| (row.string("Name").to_owned(), row.int("ActionCount")))
|
||||||
|
.expect("a findcard achievement exists");
|
||||||
|
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets");
|
||||||
|
let catalog = game_service::Catalog::load(Some(&root));
|
||||||
|
let mut profile = game_service::store::PlayerProfile::starter(
|
||||||
|
service_rpc::AccountRef::new(0, 5),
|
||||||
|
&game_service::StarterProfile::default(),
|
||||||
|
&catalog,
|
||||||
|
);
|
||||||
|
profile.collection.clear();
|
||||||
|
profile.deck.clear();
|
||||||
|
let tabs2 = LogicDataTables::instance();
|
||||||
|
let cards = tabs2.table(table::CHARACTERS).unwrap();
|
||||||
|
for i in 0..(required.max(1) as usize) {
|
||||||
|
let row = cards.get_at(i % cards.count()).unwrap();
|
||||||
|
profile.collection.push(game_service::store::OwnedCard {
|
||||||
|
card: LogicDataRef::Resolved(Arc::clone(row)),
|
||||||
|
level_index: 0,
|
||||||
|
count: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let avatar = build_avatar(&profile);
|
||||||
|
let progress = avatar
|
||||||
|
.commodities
|
||||||
|
.types
|
||||||
|
.get(COMMODITY_ACHIEVEMENT_PROGRESS)
|
||||||
|
.map(|slots| slots.iter().filter(|s| s.count >= required).count())
|
||||||
|
.unwrap_or(0);
|
||||||
|
assert!(progress > 0, "the served progress should complete a findcard tier");
|
||||||
|
let mut home = LogicHomeMode::new(LogicClientHome::default(), avatar, 0);
|
||||||
|
let before_level = home.avatar().exp_level;
|
||||||
|
let before_points = home.avatar().exp_points;
|
||||||
|
let command = LogicClaimAchievementRewardCommand {
|
||||||
|
achievement: LogicDataRef::by_name(table::ACHIEVEMENTS, &name),
|
||||||
|
header: LogicCommandHeader::default(),
|
||||||
|
};
|
||||||
|
let outcome = home.execute(&command);
|
||||||
|
assert_eq!(outcome, CommandOutcome::Applied, "the claim should be accepted");
|
||||||
|
assert!(
|
||||||
|
home.avatar().exp_points != before_points || home.avatar().exp_level != before_level,
|
||||||
|
"claiming should have granted exp"
|
||||||
|
);
|
||||||
|
assert!(matches!(home.execute(&command), CommandOutcome::Rejected(_)));
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
use crate::battle::logic_battle::LogicBattle;
|
use crate::battle::logic_battle::LogicBattle;
|
||||||
|
use crate::battle::logic_battle::BATTLE_TICKS_PER_SECOND;
|
||||||
use crate::battle::logic_component::{LogicComponent, COMPONENT_COMBAT, COMPONENT_HITPOINT};
|
use crate::battle::logic_component::{LogicComponent, COMPONENT_COMBAT, COMPONENT_HITPOINT};
|
||||||
use crate::battle::logic_game_object::{LogicGameObjectEntry, LogicObjectBody};
|
use crate::battle::logic_game_object::{LogicGameObjectEntry, LogicObjectBody};
|
||||||
use crate::battle::logic_battle::BATTLE_TICKS_PER_SECOND;
|
|
||||||
use crate::battle::logic_tilemap::SUBTILE_UNITS;
|
use crate::battle::logic_tilemap::SUBTILE_UNITS;
|
||||||
use crate::data::LogicDataRef;
|
use crate::data::LogicDataRef;
|
||||||
pub const TICK_MILLISECONDS: i32 = 50;
|
pub const TICK_MILLISECONDS: i32 = 50;
|
||||||
|
|
@ -133,7 +133,10 @@ impl LogicGameObjectEntry {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pub fn movement(&self) -> Option<&crate::battle::logic_component::LogicMovementComponent> {
|
pub fn movement(&self) -> Option<&crate::battle::logic_component::LogicMovementComponent> {
|
||||||
match self.components.get(crate::battle::logic_component::COMPONENT_MOVEMENT)? {
|
match self
|
||||||
|
.components
|
||||||
|
.get(crate::battle::logic_component::COMPONENT_MOVEMENT)?
|
||||||
|
{
|
||||||
Some(LogicComponent::Movement(component)) => Some(component),
|
Some(LogicComponent::Movement(component)) => Some(component),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
|
|
@ -149,7 +152,9 @@ impl LogicGameObjectEntry {
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pub fn combat_mut(&mut self) -> Option<&mut crate::battle::logic_component::LogicCombatComponent> {
|
pub fn combat_mut(
|
||||||
|
&mut self,
|
||||||
|
) -> Option<&mut crate::battle::logic_component::LogicCombatComponent> {
|
||||||
match self.components.get_mut(COMPONENT_COMBAT)? {
|
match self.components.get_mut(COMPONENT_COMBAT)? {
|
||||||
Some(LogicComponent::Combat(component)) => Some(component),
|
Some(LogicComponent::Combat(component)) => Some(component),
|
||||||
_ => None,
|
_ => None,
|
||||||
|
|
@ -376,7 +381,9 @@ impl LogicBattle {
|
||||||
if leg == 0 {
|
if leg == 0 {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let step = (stats.speed as i64).min(straight - stand_off as i64).min(leg);
|
let step = (stats.speed as i64)
|
||||||
|
.min(straight - stand_off as i64)
|
||||||
|
.min(leg);
|
||||||
let base = self.objects.objects[index].body.base_mut();
|
let base = self.objects.objects[index].body.base_mut();
|
||||||
base.position.x = from.0 + (dx * step / leg) as i32;
|
base.position.x = from.0 + (dx * step / leg) as i32;
|
||||||
base.position.y = from.1 + (dy * step / leg) as i32;
|
base.position.y = from.1 + (dy * step / leg) as i32;
|
||||||
|
|
@ -453,7 +460,8 @@ impl LogicBattle {
|
||||||
let mut shove = (push.0 / push.2 as i64, push.1 / push.2 as i64);
|
let mut shove = (push.0 / push.2 as i64, push.1 / push.2 as i64);
|
||||||
let allowance = (stats.speed / 2).max(1) as i64;
|
let allowance = (stats.speed / 2).max(1) as i64;
|
||||||
let travelled =
|
let travelled =
|
||||||
crate::logic_sqrt(distance_squared((0, 0), (shove.0 as i32, shove.1 as i32))) as i64;
|
crate::logic_sqrt(distance_squared((0, 0), (shove.0 as i32, shove.1 as i32)))
|
||||||
|
as i64;
|
||||||
if travelled > allowance {
|
if travelled > allowance {
|
||||||
shove.0 = shove.0 * allowance / travelled;
|
shove.0 = shove.0 * allowance / travelled;
|
||||||
shove.1 = shove.1 * allowance / travelled;
|
shove.1 = shove.1 * allowance / travelled;
|
||||||
|
|
@ -461,7 +469,12 @@ impl LogicBattle {
|
||||||
let (limit_x, limit_y) = self
|
let (limit_x, limit_y) = self
|
||||||
.tilemap
|
.tilemap
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|map| (map.width() * SUBTILE_UNITS - 1, map.height() * SUBTILE_UNITS - 1))
|
.map(|map| {
|
||||||
|
(
|
||||||
|
map.width() * SUBTILE_UNITS - 1,
|
||||||
|
map.height() * SUBTILE_UNITS - 1,
|
||||||
|
)
|
||||||
|
})
|
||||||
.unwrap_or((i32::MAX, i32::MAX));
|
.unwrap_or((i32::MAX, i32::MAX));
|
||||||
let base = self.objects.objects[index].body.base_mut();
|
let base = self.objects.objects[index].body.base_mut();
|
||||||
base.position.x = (base.position.x + shove.0 as i32).clamp(0, limit_x);
|
base.position.x = (base.position.x + shove.0 as i32).clamp(0, limit_x);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
use crate::commands::{CommandOutcome, LogicCommand, LogicReward};
|
use crate::commands::{CommandOutcome, LogicCommand, LogicReward};
|
||||||
use crate::data::{LogicDataRef, LogicRarityData};
|
use crate::data::{table, LogicDataRef, LogicDataTables, LogicRarityData};
|
||||||
use crate::model::{
|
use crate::model::{
|
||||||
ChestSource, LogicChest, LogicClientAvatar, LogicClientHome, LogicDataSlot, LogicSpell,
|
ChestSource, LogicChest, LogicClientAvatar, LogicClientHome, LogicDataSlot, LogicSpell,
|
||||||
LogicTimer, TICKS_PER_SECOND,
|
LogicTimer, TICKS_PER_SECOND,
|
||||||
|
|
@ -141,6 +141,23 @@ impl LogicHomeMode {
|
||||||
}
|
}
|
||||||
pub fn add_exp(&mut self, amount: i32) {
|
pub fn add_exp(&mut self, amount: i32) {
|
||||||
self.avatar.exp_points = self.avatar.exp_points.saturating_add(amount);
|
self.avatar.exp_points = self.avatar.exp_points.saturating_add(amount);
|
||||||
|
let tables = LogicDataTables::instance();
|
||||||
|
let Some(levels) = tables.table(table::EXP_LEVELS) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let max_level = levels.count() as i32;
|
||||||
|
while self.avatar.exp_level < max_level {
|
||||||
|
let index = (self.avatar.exp_level - 1).max(0) as usize;
|
||||||
|
let Some(row) = levels.get_at(index) else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
let threshold = row.int("ExpToNextLevel");
|
||||||
|
if threshold < 1 || self.avatar.exp_points < threshold {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
self.avatar.exp_points -= threshold;
|
||||||
|
self.avatar.exp_level += 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
pub fn add_free_diamonds(&mut self, amount: i32) {
|
pub fn add_free_diamonds(&mut self, amount: i32) {
|
||||||
self.avatar.diamonds = self.avatar.diamonds.saturating_add(amount);
|
self.avatar.diamonds = self.avatar.diamonds.saturating_add(amount);
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ impl AccountRef {
|
||||||
}
|
}
|
||||||
impl std::fmt::Display for AccountRef {
|
impl std::fmt::Display for AccountRef {
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
write!(f, "{}-{}", self.high, self.low)
|
write!(f, "LogicLong({}-{})", self.high, self.low)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ impl LogicLong {
|
||||||
}
|
}
|
||||||
impl fmt::Display for LogicLong {
|
impl fmt::Display for LogicLong {
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
write!(f, "{}-{}", self.high, self.low)
|
write!(f, "LogicLong({}-{})", self.high, self.low)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
impl From<i64> for LogicLong {
|
impl From<i64> for LogicLong {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue