New Arena building and visit site for NPCs

This commit is contained in:
Laura 2023-09-26 12:29:14 +00:00 committed by flo
parent 5d311e13bd
commit f4d48d2689
14 changed files with 1444 additions and 49 deletions

View File

@ -24,6 +24,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Added the ability to make pets sit, they wont follow nor defend you in this state - Added the ability to make pets sit, they wont follow nor defend you in this state
- Portals that spawn in place of the last staircase at old style dungeons to prevent stair cheesing - Portals that spawn in place of the last staircase at old style dungeons to prevent stair cheesing
- Mutliple singleplayer worlds and map generation UI. - Mutliple singleplayer worlds and map generation UI.
- New arena building in desert cities, suitable for PVP, also NPCs like to watch the fights too
### Changed ### Changed

View File

@ -287,3 +287,4 @@ npc-speech-dist_far = far away
npc-speech-dist_ahead = some way away npc-speech-dist_ahead = some way away
npc-speech-dist_near = nearby npc-speech-dist_near = nearby
npc-speech-dist_near_to = very close npc-speech-dist_near_to = very close
npc-speech-arena = Let's sit over there!

View File

@ -6,6 +6,7 @@
use crate::{ use crate::{
character::CharacterId, character::CharacterId,
comp::{dialogue::Subject, Content}, comp::{dialogue::Subject, Content},
util::Dir,
}; };
use rand::{seq::IteratorRandom, Rng}; use rand::{seq::IteratorRandom, Rng};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@ -230,6 +231,8 @@ pub struct RtSimController {
pub actions: VecDeque<NpcAction>, pub actions: VecDeque<NpcAction>,
pub personality: Personality, pub personality: Personality,
pub heading_to: Option<String>, pub heading_to: Option<String>,
// TODO: Maybe this should allow for looking at a specific entity target?
pub look_dir: Option<Dir>,
} }
impl RtSimController { impl RtSimController {
@ -248,7 +251,9 @@ pub enum NpcActivity {
Gather(&'static [ChunkResource]), Gather(&'static [ChunkResource]),
// TODO: Generalise to other entities? What kinds of animals? // TODO: Generalise to other entities? What kinds of animals?
HuntAnimals, HuntAnimals,
Dance, Dance(Option<Dir>),
Cheer(Option<Dir>),
Sit(Option<Dir>),
} }
/// Represents event-like actions that rtsim NPCs can perform to interact with /// Represents event-like actions that rtsim NPCs can perform to interact with

View File

@ -23,5 +23,4 @@ pub enum SettlementKindMeta {
DesertCity, DesertCity,
SavannahPit, SavannahPit,
CoastalTown, CoastalTown,
PirateHideout,
} }

View File

@ -14,6 +14,7 @@ use common::{
}, },
store::Id, store::Id,
terrain::CoordinateConversions, terrain::CoordinateConversions,
util::Dir,
}; };
use hashbrown::{HashMap, HashSet}; use hashbrown::{HashMap, HashSet};
use rand::prelude::*; use rand::prelude::*;
@ -57,6 +58,7 @@ pub struct Controller {
pub actions: Vec<NpcAction>, pub actions: Vec<NpcAction>,
pub activity: Option<NpcActivity>, pub activity: Option<NpcActivity>,
pub new_home: Option<SiteId>, pub new_home: Option<SiteId>,
pub look_dir: Option<Dir>,
} }
impl Controller { impl Controller {
@ -72,7 +74,11 @@ impl Controller {
pub fn do_hunt_animals(&mut self) { self.activity = Some(NpcActivity::HuntAnimals); } pub fn do_hunt_animals(&mut self) { self.activity = Some(NpcActivity::HuntAnimals); }
pub fn do_dance(&mut self) { self.activity = Some(NpcActivity::Dance); } pub fn do_dance(&mut self, dir: Option<Dir>) { self.activity = Some(NpcActivity::Dance(dir)); }
pub fn do_cheer(&mut self, dir: Option<Dir>) { self.activity = Some(NpcActivity::Cheer(dir)); }
pub fn do_sit(&mut self, dir: Option<Dir>) { self.activity = Some(NpcActivity::Sit(dir)); }
pub fn say(&mut self, target: impl Into<Option<Actor>>, content: comp::Content) { pub fn say(&mut self, target: impl Into<Option<Actor>>, content: comp::Content) {
self.actions.push(NpcAction::Say(target.into(), content)); self.actions.push(NpcAction::Say(target.into(), content));

View File

@ -26,6 +26,7 @@ use common::{
store::Id, store::Id,
terrain::{CoordinateConversions, SiteKindMeta, TerrainChunkSize}, terrain::{CoordinateConversions, SiteKindMeta, TerrainChunkSize},
time::DayPeriod, time::DayPeriod,
util::Dir,
}; };
use fxhash::FxHasher64; use fxhash::FxHasher64;
use itertools::{Either, Itertools}; use itertools::{Either, Itertools};
@ -270,7 +271,7 @@ impl Rule for NpcAi {
.collect::<Vec<_>>() .collect::<Vec<_>>()
}; };
// The sum of the last `SIMULATED_TICK_SKIP` tick deltatimes is the deltatime since // The sum of the last `SIMULATED_TICK_SKIP` tick deltatimes is the deltatime since
// simulated npcs ran this tick had their ai ran. // simulated npcs ran this tick had their ai ran.
let simulated_dt = last_ticks.iter().sum::<f32>(); let simulated_dt = last_ticks.iter().sum::<f32>();
@ -283,6 +284,9 @@ impl Rule for NpcAi {
.for_each(|(npc_id, controller, inbox, sentiments, known_reports, brain)| { .for_each(|(npc_id, controller, inbox, sentiments, known_reports, brain)| {
let npc = &data.npcs[*npc_id]; let npc = &data.npcs[*npc_id];
// Reset look_dir
controller.look_dir = None;
brain.action.tick(&mut NpcCtx { brain.action.tick(&mut NpcCtx {
state: ctx.state, state: ctx.state,
world: ctx.world, world: ctx.world,
@ -631,7 +635,7 @@ fn socialize() -> impl Action<EveryRange> {
if matches!(ctx.npc.mode, SimulationMode::Loaded) && socialize.should(ctx) { if matches!(ctx.npc.mode, SimulationMode::Loaded) && socialize.should(ctx) {
// Sometimes dance // Sometimes dance
if ctx.rng.gen_bool(0.15) { if ctx.rng.gen_bool(0.15) {
return just(|ctx, _| ctx.controller.do_dance()) return just(|ctx, _| ctx.controller.do_dance(None))
.repeat() .repeat()
.stop_if(timeout(6.0)) .stop_if(timeout(6.0))
.debug(|| "dancing") .debug(|| "dancing")
@ -692,11 +696,11 @@ fn adventure() -> impl Action<DefaultState> {
.unwrap_or_default(); .unwrap_or_default();
// Travel to the site // Travel to the site
important(just(move |ctx, _| ctx.controller.say(None, Content::localized_with_args("npc-speech-moving_on", [("site", site_name.clone())]))) important(just(move |ctx, _| ctx.controller.say(None, Content::localized_with_args("npc-speech-moving_on", [("site", site_name.clone())])))
.then(travel_to_site(tgt_site, 0.6)) .then(travel_to_site(tgt_site, 0.6))
// Stop for a few minutes // Stop for a few minutes
.then(villager(tgt_site).repeat().stop_if(timeout(wait_time))) .then(villager(tgt_site).repeat().stop_if(timeout(wait_time)))
.map(|_, _| ()) .map(|_, _| ())
.boxed(), .boxed(),
) )
} else { } else {
casual(finish().boxed()) casual(finish().boxed())
@ -841,6 +845,49 @@ fn villager(visiting_site: SiteId) -> impl Action<DefaultState> {
.debug(|| "find somewhere to sleep"), .debug(|| "find somewhere to sleep"),
); );
// Villagers with roles should perform those roles // Villagers with roles should perform those roles
}
// Visiting villagers in DesertCity who are not Merchants should sit down in the Arena during the day
else if matches!(ctx.state.data().sites[visiting_site].world_site.map(|ws| &ctx.index.sites.get(ws).kind), Some(SiteKind::DesertCity(_)))
&& !matches!(ctx.npc.profession(), Some(Profession::Merchant | Profession::Guard))
&& ctx.rng.gen_bool(1.0 / 3.0)
{
let wait_time = ctx.rng.gen_range(100.0..300.0);
if let Some(ws_id) = ctx.state.data().sites[visiting_site].world_site
&& let Some(ws) = ctx.index.sites.get(ws_id).site2()
&& let Some(arena) = ws.plots().find_map(|p| match p.kind() { PlotKind::DesertCityArena(a) => Some(a), _ => None})
{
// We don't use Z coordinates for seats because they are complicated to calculate from the Ramp procedural generation
// and using goto_2d seems to work just fine. However it also means that NPC will never go seat on the stands
// on the first floor of the arena. This is a compromise that was made because in the current arena procedural generation
// there is also no pathways to the stands on the first floor for NPCs.
let arena_center = Vec3::new(arena.center.x, arena.center.y, arena.base).as_::<f32>();
let stand_dist = arena.stand_dist as f32;
let seat_var_width = ctx.rng.gen_range(0..arena.stand_width) as f32;
let seat_var_length = ctx.rng.gen_range(-arena.stand_length..arena.stand_length) as f32;
// Select a seat on one of the 4 arena stands
let seat = match ctx.rng.gen_range(0..4) {
0 => Vec3::new(arena_center.x - stand_dist + seat_var_width, arena_center.y + seat_var_length, arena_center.z),
1 => Vec3::new(arena_center.x + stand_dist - seat_var_width, arena_center.y + seat_var_length, arena_center.z),
2 => Vec3::new(arena_center.x + seat_var_length, arena_center.y - stand_dist + seat_var_width, arena_center.z),
_ => Vec3::new(arena_center.x + seat_var_length, arena_center.y + stand_dist - seat_var_width, arena_center.z),
};
let look_dir = Dir::from_unnormalized(arena_center - seat);
// Walk to an arena seat, cheer, sit and dance
return casual(just(move |ctx, _| ctx.controller.say(None, Content::localized("npc-speech-arena")))
.then(goto_2d(seat.xy(), 0.6, 1.0).debug(|| "go to arena"))
// Turn toward the centre of the arena and watch the action!
.then(choose(move |ctx, _| if ctx.rng.gen_bool(0.3) {
casual(just(move |ctx,_| ctx.controller.do_cheer(look_dir)).repeat().stop_if(timeout(5.0)))
} else if ctx.rng.gen_bool(0.15) {
casual(just(move |ctx,_| ctx.controller.do_dance(look_dir)).repeat().stop_if(timeout(5.0)))
} else {
casual(just(move |ctx,_| ctx.controller.do_sit(look_dir)).repeat().stop_if(timeout(15.0)))
})
.repeat()
.stop_if(timeout(wait_time)))
.map(|_, _| ())
.boxed());
}
} else if matches!(ctx.npc.profession(), Some(Profession::Herbalist)) && ctx.rng.gen_bool(0.8) } else if matches!(ctx.npc.profession(), Some(Profession::Herbalist)) && ctx.rng.gen_bool(0.8)
{ {
if let Some(forest_wpos) = find_forest(ctx) { if let Some(forest_wpos) = find_forest(ctx) {

View File

@ -220,7 +220,9 @@ fn on_tick(ctx: EventCtx<SimulateNpcs, OnTick>) {
NpcActivity::Goto(_, _) NpcActivity::Goto(_, _)
| NpcActivity::Gather(_) | NpcActivity::Gather(_)
| NpcActivity::HuntAnimals | NpcActivity::HuntAnimals
| NpcActivity::Dance, | NpcActivity::Dance(_)
| NpcActivity::Cheer(_)
| NpcActivity::Sit(_),
) => {}, ) => {},
None => {}, None => {},
} }
@ -246,7 +248,11 @@ fn on_tick(ctx: EventCtx<SimulateNpcs, OnTick>) {
} }
}, },
Some( Some(
NpcActivity::Gather(_) | NpcActivity::HuntAnimals | NpcActivity::Dance, NpcActivity::Gather(_)
| NpcActivity::HuntAnimals
| NpcActivity::Dance(_)
| NpcActivity::Cheer(_)
| NpcActivity::Sit(_),
) => { ) => {
// TODO: Maybe they should walk around randomly // TODO: Maybe they should walk around randomly
// when gathering resources? // when gathering resources?

View File

@ -238,6 +238,7 @@ impl<'a> AgentData<'a> {
} }
agent.action_state.timers[ActionTimers::TimerIdle as usize] = 0.0; agent.action_state.timers[ActionTimers::TimerIdle as usize] = 0.0;
'activity: { 'activity: {
match agent.rtsim_controller.activity { match agent.rtsim_controller.activity {
Some(NpcActivity::Goto(travel_to, speed_factor)) => { Some(NpcActivity::Goto(travel_to, speed_factor)) => {
@ -371,10 +372,46 @@ impl<'a> AgentData<'a> {
controller.push_action(ControlAction::Dance); controller.push_action(ControlAction::Dance);
break 'activity; // Don't fall through to idle wandering break 'activity; // Don't fall through to idle wandering
}, },
Some(NpcActivity::Dance) => { Some(NpcActivity::Dance(dir)) => {
// Look at targets specified by rtsim
if let Some(look_dir) = dir {
controller.inputs.look_dir = look_dir;
if self.ori.look_dir().dot(look_dir.to_vec()) < 0.95 {
controller.inputs.move_dir = look_dir.to_vec().xy() * 0.01;
break 'activity;
} else {
controller.inputs.move_dir = Vec2::zero();
}
}
controller.push_action(ControlAction::Dance); controller.push_action(ControlAction::Dance);
break 'activity; // Don't fall through to idle wandering break 'activity; // Don't fall through to idle wandering
}, },
Some(NpcActivity::Cheer(dir)) => {
if let Some(look_dir) = dir {
controller.inputs.look_dir = look_dir;
if self.ori.look_dir().dot(look_dir.to_vec()) < 0.95 {
controller.inputs.move_dir = look_dir.to_vec().xy() * 0.01;
break 'activity;
} else {
controller.inputs.move_dir = Vec2::zero();
}
}
controller.push_action(ControlAction::Talk);
break 'activity; // Don't fall through to idle wandering
},
Some(NpcActivity::Sit(dir)) => {
if let Some(look_dir) = dir {
controller.inputs.look_dir = look_dir;
if self.ori.look_dir().dot(look_dir.to_vec()) < 0.95 {
controller.inputs.move_dir = look_dir.to_vec().xy() * 0.01;
break 'activity;
} else {
controller.inputs.move_dir = Vec2::zero();
}
}
controller.push_action(ControlAction::Sit);
break 'activity; // Don't fall through to idle wandering
},
Some(NpcActivity::HuntAnimals) => { Some(NpcActivity::HuntAnimals) => {
if rng.gen::<f32>() < 0.1 { if rng.gen::<f32>() < 0.1 {
self.choose_target( self.choose_target(

View File

@ -440,6 +440,7 @@ impl<'a> System<'a> for Sys {
// Update entity state // Update entity state
if let Some(agent) = agent { if let Some(agent) = agent {
agent.rtsim_controller.personality = npc.personality; agent.rtsim_controller.personality = npc.personality;
agent.rtsim_controller.look_dir = npc.controller.look_dir;
agent.rtsim_controller.activity = npc.controller.activity; agent.rtsim_controller.activity = npc.controller.activity;
agent agent
.rtsim_controller .rtsim_controller

View File

@ -261,19 +261,8 @@ impl Civs {
let world_dims = ctx.sim.get_aabr(); let world_dims = ctx.sim.get_aabr();
for _ in 0..initial_civ_count * 3 { for _ in 0..initial_civ_count * 3 {
attempt(5, || { attempt(5, || {
let (loc, kind) = match ctx.rng.gen_range(0..84) { let (loc, kind) = match ctx.rng.gen_range(0..79) {
0..=5 => ( 0..=4 => {
find_site_loc(
&mut ctx,
&ProximityRequirementsBuilder::new()
.avoid_all_of(this.castle_enemies(), 40)
.close_to_one_of(this.towns(), 20)
.finalize(&world_dims),
&SiteKind::Castle,
)?,
SiteKind::Castle,
),
28..=31 => {
if index.features().site2_giant_trees { if index.features().site2_giant_trees {
( (
find_site_loc( find_site_loc(
@ -298,7 +287,7 @@ impl Civs {
) )
} }
}, },
32..=37 => ( 5..=10 => (
find_site_loc( find_site_loc(
&mut ctx, &mut ctx,
&ProximityRequirementsBuilder::new() &ProximityRequirementsBuilder::new()
@ -308,8 +297,7 @@ impl Civs {
)?, )?,
SiteKind::Gnarling, SiteKind::Gnarling,
), ),
// 32..=37 => (SiteKind::Citadel, (&castle_enemies, 20)), 11..=16 => (
38..=43 => (
find_site_loc( find_site_loc(
&mut ctx, &mut ctx,
&ProximityRequirementsBuilder::new() &ProximityRequirementsBuilder::new()
@ -319,7 +307,7 @@ impl Civs {
)?, )?,
SiteKind::ChapelSite, SiteKind::ChapelSite,
), ),
44..=49 => ( 17..=22 => (
find_site_loc( find_site_loc(
&mut ctx, &mut ctx,
&ProximityRequirementsBuilder::new() &ProximityRequirementsBuilder::new()
@ -329,17 +317,7 @@ impl Civs {
)?, )?,
SiteKind::Adlet, SiteKind::Adlet,
), ),
/*50..=55 => ( 23..=35 => (
find_site_loc(
&mut ctx,
&ProximityRequirementsBuilder::new()
.avoid_all_of(this.mine_site_enemies(), 40)
.finalize(&world_dims),
&SiteKind::DwarvenMine,
)?,
SiteKind::DwarvenMine,
),*/
56..=68 => (
find_site_loc( find_site_loc(
&mut ctx, &mut ctx,
&ProximityRequirementsBuilder::new() &ProximityRequirementsBuilder::new()
@ -349,7 +327,7 @@ impl Civs {
)?, )?,
SiteKind::PirateHideout, SiteKind::PirateHideout,
), ),
69..=75 => ( 36..=42 => (
find_site_loc( find_site_loc(
&mut ctx, &mut ctx,
&ProximityRequirementsBuilder::new() &ProximityRequirementsBuilder::new()
@ -359,6 +337,29 @@ impl Civs {
)?, )?,
SiteKind::JungleRuin, SiteKind::JungleRuin,
), ),
/*43..=48 => (
find_site_loc(
&mut ctx,
&ProximityRequirementsBuilder::new()
.avoid_all_of(this.mine_site_enemies(), 40)
.finalize(&world_dims),
&SiteKind::DwarvenMine,
)?,
SiteKind::DwarvenMine,
),
49..=54 => (
find_site_loc(
&mut ctx,
&ProximityRequirementsBuilder::new()
.avoid_all_of(this.castle_enemies(), 40)
.close_to_one_of(this.towns(), 20)
.finalize(&world_dims),
&SiteKind::Castle,
)?,
SiteKind::Castle,
),
55..=60 => (SiteKind::Citadel, (&castle_enemies, 20)),
*/
_ => ( _ => (
find_site_loc( find_site_loc(
&mut ctx, &mut ctx,

View File

@ -373,7 +373,6 @@ impl Site {
| SiteKind::CliffTown(_) | SiteKind::CliffTown(_)
| SiteKind::SavannahPit(_) | SiteKind::SavannahPit(_)
| SiteKind::CoastalTown(_) | SiteKind::CoastalTown(_)
| SiteKind::PirateHideout(_)
| SiteKind::DesertCity(_) | SiteKind::DesertCity(_)
| SiteKind::Settlement(_) | SiteKind::Settlement(_)
) )
@ -417,9 +416,6 @@ impl SiteKind {
SiteKind::CoastalTown(_) => { SiteKind::CoastalTown(_) => {
Some(SiteKindMeta::Settlement(SettlementKindMeta::CoastalTown)) Some(SiteKindMeta::Settlement(SettlementKindMeta::CoastalTown))
}, },
SiteKind::PirateHideout(_) => {
Some(SiteKindMeta::Settlement(SettlementKindMeta::PirateHideout))
},
SiteKind::DesertCity(_) => { SiteKind::DesertCity(_) => {
Some(SiteKindMeta::Settlement(SettlementKindMeta::DesertCity)) Some(SiteKindMeta::Settlement(SettlementKindMeta::DesertCity))
}, },

View File

@ -1144,6 +1144,29 @@ impl Site {
site.make_plaza(land, &mut rng); site.make_plaza(land, &mut rng);
let size = 17.0 as i32;
let aabr = Aabr {
min: Vec2::broadcast(-size),
max: Vec2::broadcast(size),
};
let desert_city_arena =
plot::DesertCityArena::generate(land, &mut reseed(&mut rng), &site, aabr);
let desert_city_arena_alt = desert_city_arena.alt;
let plot = site.create_plot(Plot {
kind: PlotKind::DesertCityArena(desert_city_arena),
root_tile: aabr.center(),
tiles: aabr_tiles(aabr).collect(),
seed: rng.gen(),
});
site.blit_aabr(aabr, Tile {
kind: TileKind::Building,
plot: Some(plot),
hard_alt: Some(desert_city_arena_alt),
});
let build_chance = Lottery::from(vec![(20.0, 1), (10.0, 2)]); let build_chance = Lottery::from(vec![(20.0, 1), (10.0, 2)]);
let mut temples = 0; let mut temples = 0;
@ -1675,6 +1698,9 @@ impl Site {
PlotKind::DesertCityTemple(desert_city_temple) => { PlotKind::DesertCityTemple(desert_city_temple) => {
desert_city_temple.render_collect(self, canvas) desert_city_temple.render_collect(self, canvas)
}, },
PlotKind::DesertCityArena(desert_city_arena) => {
desert_city_arena.render_collect(self, canvas)
},
PlotKind::Citadel(citadel) => citadel.render_collect(self, canvas), PlotKind::Citadel(citadel) => citadel.render_collect(self, canvas),
PlotKind::Bridge(bridge) => bridge.render_collect(self, canvas), PlotKind::Bridge(bridge) => bridge.render_collect(self, canvas),
PlotKind::PirateHideout(pirate_hideout) => { PlotKind::PirateHideout(pirate_hideout) => {

View File

@ -5,6 +5,7 @@ mod citadel;
mod cliff_tower; mod cliff_tower;
mod coastal_house; mod coastal_house;
mod coastal_workshop; mod coastal_workshop;
mod desert_city_arena;
mod desert_city_multiplot; mod desert_city_multiplot;
mod desert_city_temple; mod desert_city_temple;
pub mod dungeon; pub mod dungeon;
@ -23,9 +24,9 @@ mod workshop;
pub use self::{ pub use self::{
adlet::AdletStronghold, bridge::Bridge, castle::Castle, citadel::Citadel, adlet::AdletStronghold, bridge::Bridge, castle::Castle, citadel::Citadel,
cliff_tower::CliffTower, coastal_house::CoastalHouse, coastal_workshop::CoastalWorkshop, cliff_tower::CliffTower, coastal_house::CoastalHouse, coastal_workshop::CoastalWorkshop,
desert_city_multiplot::DesertCityMultiPlot, desert_city_temple::DesertCityTemple, desert_city_arena::DesertCityArena, desert_city_multiplot::DesertCityMultiPlot,
dungeon::Dungeon, dwarven_mine::DwarvenMine, giant_tree::GiantTree, desert_city_temple::DesertCityTemple, dungeon::Dungeon, dwarven_mine::DwarvenMine,
gnarling::GnarlingFortification, house::House, jungle_ruin::JungleRuin, giant_tree::GiantTree, gnarling::GnarlingFortification, house::House, jungle_ruin::JungleRuin,
pirate_hideout::PirateHideout, savannah_hut::SavannahHut, savannah_pit::SavannahPit, pirate_hideout::PirateHideout, savannah_hut::SavannahHut, savannah_pit::SavannahPit,
savannah_workshop::SavannahWorkshop, sea_chapel::SeaChapel, workshop::Workshop, savannah_workshop::SavannahWorkshop, sea_chapel::SeaChapel, workshop::Workshop,
}; };
@ -74,6 +75,7 @@ pub enum PlotKind {
Workshop(Workshop), Workshop(Workshop),
DesertCityMultiPlot(DesertCityMultiPlot), DesertCityMultiPlot(DesertCityMultiPlot),
DesertCityTemple(DesertCityTemple), DesertCityTemple(DesertCityTemple),
DesertCityArena(DesertCityArena),
SeaChapel(SeaChapel), SeaChapel(SeaChapel),
JungleRuin(JungleRuin), JungleRuin(JungleRuin),
Plaza, Plaza,

File diff suppressed because it is too large Load Diff