diff --git a/.gitignore b/.gitignore index 96b7feb..1c35c13 100644 --- a/.gitignore +++ b/.gitignore @@ -6,5 +6,8 @@ node_modules/ frontend/dist/ frontend/node_modules/ *.log -engine/lofp -VIBECTL.md +engine/lofp +VIBECTL.md +.vscode/ +__debug_bin* +start.batstart.bat diff --git a/Dockerfile b/Dockerfile index 8d09f9a..b5a3122 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,6 +21,9 @@ FROM alpine:3.21 RUN apk add --no-cache ca-certificates WORKDIR /app +# Copy original GM HTML pages +COPY ["original/GM Pages/", "gm-pages/"] + # Copy binary COPY --from=backend /lofp . diff --git a/engine/internal/api/api.go b/engine/internal/api/api.go index b0434e0..f3ad828 100644 --- a/engine/internal/api/api.go +++ b/engine/internal/api/api.go @@ -58,14 +58,14 @@ type ClientConn interface { type Session struct { Player *engine.Player Conn ClientConn - CaptureID string // active capture session ID, empty if not recording - lastCmdTime time.Time // rate limiting: last command timestamp - cmdCount int // rate limiting: commands in current window + CaptureID string // active capture session ID, empty if not recording + lastCmdTime time.Time // rate limiting: last command timestamp + cmdCount int // rate limiting: commands in current window chatTimes []time.Time // chat flood: timestamps of recent broadcasts cmdTimes []time.Time // command rate: sliding window for 10/10s limit - authFailures int // auth attempt failures (disconnect after 3) - lastActivity time.Time // idle timeout tracking - quitSent bool // QUIT already broadcast departure + authFailures int // auth attempt failures (disconnect after 3) + lastActivity time.Time // idle timeout tracking + quitSent bool // QUIT already broadcast departure } // wsConn wraps a gorilla WebSocket connection to implement ClientConn. @@ -103,7 +103,6 @@ func (w *wsConn) RemoteAddr() string { return w.conn.RemoteAddr().String() } - // getClientIP extracts the real client IP from the request, preferring // Fly-Client-IP (set by Fly.io proxy), then X-Forwarded-For, then RemoteAddr. func getClientIP(r *http.Request) string { @@ -348,6 +347,18 @@ func (s *Server) setupRoutes() { api.HandleFunc("/admin/characters/deleted", s.handleAdminListDeletedCharacters).Methods("GET") api.HandleFunc("/admin/characters/{firstName}/recover", s.handleAdminRecoverCharacter).Methods("PUT") + gmPagesDir := "/app/gm-pages" + + if info, err := os.Stat(gmPagesDir); err == nil && info.IsDir() { + s.router.PathPrefix("/gm-pages/").Handler( + http.StripPrefix( + "/gm-pages/", + http.FileServer(http.Dir(gmPagesDir)), + ), + ) + + log.Printf("Serving GM pages from %s", gmPagesDir) + } // Serve static frontend files in production (if /app/static exists) staticDir := os.Getenv("LOFP_STATIC_DIR") if staticDir == "" { @@ -490,7 +501,9 @@ func (s *Server) handleGameWS(w http.ResponseWriter, r *http.Request) { defer func() { s.connMu.Lock() s.connsByIP[ip]-- - if s.connsByIP[ip] <= 0 { delete(s.connsByIP, ip) } + if s.connsByIP[ip] <= 0 { + delete(s.connsByIP, ip) + } s.connMu.Unlock() }() @@ -773,7 +786,9 @@ func (s *Server) handleGameWS(w http.ResponseWriter, r *http.Request) { cutoff := now.Add(-10 * time.Second) var recentCmds []time.Time for _, t := range session.cmdTimes { - if t.After(cutoff) { recentCmds = append(recentCmds, t) } + if t.After(cutoff) { + recentCmds = append(recentCmds, t) + } } session.cmdTimes = append(recentCmds, now) if len(session.cmdTimes) > 10 { @@ -787,6 +802,7 @@ func (s *Server) handleGameWS(w http.ResponseWriter, r *http.Request) { ctx := context.Background() playerRoom := session.Player.RoomNumber result := s.engine.ProcessCommand(ctx, session.Player, cmd.Input) + result.PlayerState = session.Player result.PromptIndicators = session.Player.PromptIndicators() s.sendResult(session, result) @@ -812,7 +828,9 @@ func (s *Server) handleGameWS(w http.ResponseWriter, r *http.Request) { cutoff := now.Add(-10 * time.Second) var recent []time.Time for _, t := range session.chatTimes { - if t.After(cutoff) { recent = append(recent, t) } + if t.After(cutoff) { + recent = append(recent, t) + } } session.chatTimes = recent if len(session.chatTimes) >= 5 { diff --git a/engine/internal/api/ssh.go b/engine/internal/api/ssh.go index b13d737..0b012c1 100644 --- a/engine/internal/api/ssh.go +++ b/engine/internal/api/ssh.go @@ -665,8 +665,8 @@ func (s *Server) sshCharacterSelect(sc *sshConn, ctx context.Context, account *a func (s *Server) sshCreateCharacter(sc *sshConn, ctx context.Context, accountID string) *engine.Player { existing, _ := s.engine.ListPlayersByAccount(ctx, accountID) - if len(existing) >= 8 { - sc.writeLine(ansiRed + "You can have at most 8 characters." + ansiReset) + if len(existing) >= 3 { + sc.writeLine(ansiRed + "You can have at most 3 characters." + ansiReset) return nil } @@ -766,6 +766,11 @@ func (s *Server) sshCommandLoop(ctx context.Context, session *Session, sc *sshCo } result := s.engine.ProcessCommand(ctx, session.Player, input) + + // Expire timed effects + timerMessages := s.engine.UpdatePlayerTimers(session.Player) + result.Messages = append(result.Messages, timerMessages...) + result.PlayerState = session.Player result.PromptIndicators = session.Player.PromptIndicators() s.sendResult(session, result) diff --git a/engine/internal/api/telnet.go b/engine/internal/api/telnet.go index b583992..a6caaed 100644 --- a/engine/internal/api/telnet.go +++ b/engine/internal/api/telnet.go @@ -28,14 +28,14 @@ const ( sbByte = 250 // sub-negotiation begin seByte = 240 // sub-negotiation end - optEcho = 1 // Echo - optSGA = 3 // Suppress Go-Ahead - optTType = 24 // Terminal Type (MTTS) - optNAWS = 31 // Negotiate About Window Size - optMSDP = 69 // MUD Server Data Protocol - optMSSP = 70 // MUD Server Status Protocol - optMCCP2 = 86 // MUD Client Compression Protocol v2 - optMXP = 91 // MUD eXtension Protocol + optEcho = 1 // Echo + optSGA = 3 // Suppress Go-Ahead + optTType = 24 // Terminal Type (MTTS) + optNAWS = 31 // Negotiate About Window Size + optMSDP = 69 // MUD Server Data Protocol + optMSSP = 70 // MUD Server Status Protocol + optMCCP2 = 86 // MUD Client Compression Protocol v2 + optMXP = 91 // MUD eXtension Protocol optGMCP = 201 // Generic MUD Communication Protocol // MSSP sub-negotiation @@ -95,7 +95,7 @@ type telnetConn struct { passwordMode bool // MCCP2: compressed writer (nil until activated) - compWriter *zlib.Writer + compWriter *zlib.Writer compActivated bool // Server reference for MSSP player count @@ -382,13 +382,13 @@ func (t *telnetConn) negotiate() { // We switch to WILL ECHO only for password prompts t.conn.Write([]byte{ iacByte, wontByte, optEcho, // Client handles echo (normal) - iacByte, willByte, optSGA, // Suppress go-ahead - iacByte, doByte, optNAWS, // Request terminal size - iacByte, willByte, optGMCP, // Offer GMCP + iacByte, willByte, optSGA, // Suppress go-ahead + iacByte, doByte, optNAWS, // Request terminal size + iacByte, willByte, optGMCP, // Offer GMCP iacByte, willByte, optMCCP2, // Offer MCCP2 - iacByte, willByte, optMSSP, // Offer MSSP - iacByte, willByte, optMSDP, // Offer MSDP - iacByte, doByte, optMXP, // Request MXP + iacByte, willByte, optMSSP, // Offer MSSP + iacByte, willByte, optMSDP, // Offer MSDP + iacByte, doByte, optMXP, // Request MXP }) // Read and respond to client negotiation for up to 2 seconds @@ -1341,8 +1341,8 @@ func (s *Server) telnetCharacterSelect(tc *telnetConn, ctx context.Context, acco func (s *Server) telnetCreateCharacter(tc *telnetConn, ctx context.Context, accountID string) *engine.Player { existing, _ := s.engine.ListPlayersByAccount(ctx, accountID) - if len(existing) >= 8 { - tc.writeLine(ansiRed + "You can have at most 8 characters." + ansiReset) + if len(existing) >= 3 { + tc.writeLine(ansiRed + "You can have at most 3 characters." + ansiReset) return nil } @@ -1491,6 +1491,11 @@ func (s *Server) telnetCommandLoop(ctx context.Context, session *Session, tc *te } result := s.engine.ProcessCommand(ctx, session.Player, input) + + // Expire timed effects + //timerMessages := s.engine.UpdatePlayerTimers(session.Player) + //result.Messages = append(result.Messages, timerMessages...) + result.PlayerState = session.Player result.PromptIndicators = session.Player.PromptIndicators() s.sendResult(session, result) diff --git a/engine/internal/engine/combat.go b/engine/internal/engine/combat.go index d4558fb..4926a34 100644 --- a/engine/internal/engine/combat.go +++ b/engine/internal/engine/combat.go @@ -8,6 +8,7 @@ import ( "time" "github.com/jonradoff/lofp/internal/gameworld" + "go.mongodb.org/mongo-driver/v2/bson" ) // Combat stance constants @@ -41,9 +42,9 @@ type CombatTarget struct { // Level increases when total build points reach 20 + 10*(level+1). var xpPerBP = []int{ - 0, // level 0 (unused) - 100, 200, 400, 600, 800, 1000, 1200, 1400, 1600, 2000, // 1-10 - 2400, 2700, 3200, 4000, 4800, 5600, 6400, 7200, 8000, 8800, // 11-20 + 0, // level 0 (unused) + 100, 200, 400, 600, 800, 1000, 1200, 1400, 1600, 2000, // 1-10 + 2400, 2700, 3200, 4000, 4800, 5600, 6400, 7200, 8000, 8800, // 11-20 9600, 10400, 11200, 12000, 12800, 13600, 14400, 15200, 16000, 16800, // 21-30 17600, 18400, 19200, 20000, 20800, 21600, 22400, 23200, 24000, 24800, // 31-40 25600, 26400, 27200, 28000, 28800, 29600, 30400, 31200, 32000, 32800, // 41-50 @@ -109,6 +110,20 @@ func recalcBuildPoints(player *Player) (leveledUp bool) { if player.BuildPoints < 0 { player.BuildPoints = 0 } + + // Add permanent gains for any missing levels. + if lvl > oldLevel { + for level := oldLevel + 1; level <= lvl; level++ { + player.MaxBodyPoints += player.Constitution / 10 + player.BodyPoints = player.MaxBodyPoints + player.MaxFatigue += player.Constitution / 15 + player.Fatigue = player.MaxFatigue + player.MaxMana += (player.Willpower + player.Empathy) / 15 + player.Mana = player.MaxMana + player.MaxPsi += player.Willpower / 10 + player.Psi = player.MaxPsi + } + } player.Level = lvl return player.Level > oldLevel @@ -285,17 +300,22 @@ func calcToHit(attackRating, defenseRating int) int { func playerAttackRating(player *Player, weaponDef *gameworld.ItemDef) int { rating := 50 rating += player.Level * 3 + if weaponDef != nil { skillID := weaponSkillForType(weaponDef.Type) - rating += player.Skills[skillID] * 5 // +5 per weapon skill rank (from skills.txt) + rating += player.Skills[skillID] * 5 + } else if player.WolfForm { + // Wolfling natural weapons + rating += player.Skills[4] * 5 } else { - // Unarmed: martial arts skill - rating += player.Skills[24] * 5 // Martial Arts +5 per rank + // Normal unarmed combat + rating += player.Skills[24] * 5 } + if weaponDef != nil && (weaponDef.Type == "BOW_WEAPON" || weaponDef.Type == "THROWN_WEAPON") { - rating += player.Agility / 5 + rating += player.EffectiveStat(StatAgility) / 5 } else { - rating += player.Strength / 5 + rating += player.EffectiveStat(StatStrength) / 5 } switch player.Stance { case StanceOffensive: @@ -318,11 +338,11 @@ func playerAttackRating(player *Player, weaponDef *gameworld.ItemDef) int { return rating } -func playerDefenseRating(player *Player) int { +func (e *GameEngine) playerDefenseRating(player *Player) int { rating := 25 rating += player.Level * 3 rating += player.Skills[6] * 5 // Dodge & Parry: +5 per rank - rating += player.Agility / 5 + rating += player.EffectiveStat(StatAgility) / 5 // Martial Arts defense bonus: +2 per rank if unarmed if player.Wielded == nil { rating += player.Skills[24] * 2 @@ -346,6 +366,14 @@ func playerDefenseRating(player *Player) int { case 3: rating -= 10 } + + if player.Offhand != nil { + def := e.items[player.Offhand.Archetype] + if isShield(def) { + rating += def.Parameter1 + } + } + return rating } @@ -369,11 +397,11 @@ func playerDamage(player *Player, weaponDef *gameworld.ItemDef) int { if weaponDef == nil { if player.WolfForm { // Wolf form: claw/bite — higher base damage - return rand.Intn(8) + 3 + player.Strength/10 + return rand.Intn(8) + 3 + player.EffectiveStat(StatStrength)/10 } // Martial Arts: +1 base damage per rank, +1 max damage per 2 ranks maSkill := player.Skills[24] - baseDmg := rand.Intn(3+maSkill/2) + 1 + maSkill + player.Strength/20 + baseDmg := rand.Intn(3+maSkill/2) + 1 + maSkill + player.EffectiveStat(StatStrength)/20 return baseDmg } maxDmg := weaponDef.Parameter1 @@ -382,9 +410,9 @@ func playerDamage(player *Player, weaponDef *gameworld.ItemDef) int { } dmg := rand.Intn(maxDmg) + 1 if weaponDef.Type == "BOW_WEAPON" || weaponDef.Type == "THROWN_WEAPON" { - dmg += player.Agility / 10 + dmg += player.EffectiveStat(StatAgility) / 10 } else { - dmg += player.Strength / 10 + dmg += player.EffectiveStat(StatStrength) / 10 } if player.Stance == StanceBerserk { dmg = dmg * 12 / 10 @@ -752,11 +780,16 @@ func (e *GameEngine) doAttackMonster(ctx context.Context, player *Player, target } } + //apply room lighting conditions + vMod := e.visibilityCombatModifier(player, player.RoomNumber) + // Resolve to-hit - attackRating := playerAttackRating(player, weaponDef) + wMod - fatPenalty + attackRating := playerAttackRating(player, weaponDef) + wMod - fatPenalty + vMod + if inst.Stunned { attackRating += 20 // bonus for attacking stunned target } + monDefense := def.Defense + inst.DefenseBonus toHit := calcToHit(attackRating, monDefense) roll := rand.Intn(100) + 1 @@ -792,7 +825,10 @@ func (e *GameEngine) doAttackMonster(ctx context.Context, player *Player, target rtSec := 5 player.RoundTimeExpiry = time.Now().Add(time.Duration(rtSec) * time.Second) result.Messages = append(result.Messages, fmt.Sprintf("[Round: %d sec]", rtSec)) - if player.Hidden { player.Hidden = false; result.Messages = append([]string{"You reveal yourself!"}, result.Messages...) } + if player.Hidden { + player.Hidden = false + result.Messages = append([]string{"You reveal yourself!"}, result.Messages...) + } e.SavePlayer(ctx, player) result.PlayerState = player return result @@ -861,7 +897,7 @@ func (e *GameEngine) doAttackMonster(ctx context.Context, player *Player, target } } - killed := e.damageMonster(inst.ID, dmg) + killed := e.damageMonster(player, inst.ID, dmg) // Weapon poison if poisonLvl := weaponPoisonLevel(player.Wielded); poisonLvl > 0 && !killed { @@ -913,9 +949,9 @@ func (e *GameEngine) doAttackMonster(ctx context.Context, player *Player, target // Roundtime: base 5, reduced by quickness and Combat Maneuvering rtSeconds := 5 - if player.Quickness > 80 { + if player.EffectiveStat(StatQuickness) > 80 { rtSeconds = 3 - } else if player.Quickness > 50 { + } else if player.EffectiveStat(StatQuickness) > 50 { rtSeconds = 4 } // Combat Maneuvering: -1 sec per rank (from skills.txt) @@ -1003,46 +1039,50 @@ func (e *GameEngine) monsterAttackPlayer(inst *MonsterInstance, def *gameworld.M // Combat Maneuvering: 2% per rank chance to dodge special attack (max 95%) combatManeuver := player.Skills[10] dodgeChance := combatManeuver * 2 - if dodgeChance > 95 { dodgeChance = 95 } + if dodgeChance > 95 { + dodgeChance = 95 + } if dodgeChance > 0 && rand.Intn(100) < dodgeChance { playerMsgs = append(playerMsgs, fmt.Sprintf("%s%s uses a special attack, but you dodge it!", capArt, name)) } else { - specText := def.TextOverrides["TEXX"] - if specText != "" { - specText = strings.Replace(specText, "%s", capArt+name, 1) - specText = strings.Replace(specText, "%s", player.FirstName, 1) - } else { - specText = fmt.Sprintf("%s%s uses a special attack on %s!", capArt, name, player.FirstName) - } + specText := def.TextOverrides["TEXX"] + if specText != "" { + specText = strings.Replace(specText, "%s", capArt+name, 1) + specText = strings.Replace(specText, "%s", player.FirstName, 1) + } else { + specText = fmt.Sprintf("%s%s uses a special attack on %s!", capArt, name, player.FirstName) + } - armorPct := playerArmorPercent(player, e.items) - specDmg = applyArmor(specDmg, armorPct) + armorPct := playerArmorPercent(player, e.items) + specDmg = applyArmor(specDmg, armorPct) - // Endurance: 1% elemental damage reduction per rank (max 50%) - enduranceSkill := player.Skills[11] - if enduranceSkill > 0 && (specType == "Heat" || specType == "Cold" || specType == "Electric") { - reduction := enduranceSkill - if reduction > 50 { reduction = 50 } - specDmg = specDmg * (100 - reduction) / 100 - } - _ = specType + // Endurance: 1% elemental damage reduction per rank (max 50%) + enduranceSkill := player.Skills[11] + if enduranceSkill > 0 && (specType == "Heat" || specType == "Cold" || specType == "Electric") { + reduction := enduranceSkill + if reduction > 50 { + reduction = 50 + } + specDmg = specDmg * (100 - reduction) / 100 + } + _ = specType - part := randomBodyPart("HUMAN") - severity := damageSeverity(specDmg) - player.BodyPoints -= specDmg - if player.BodyPoints < 0 { - player.BodyPoints = 0 - } + part := randomBodyPart("HUMAN") + severity := damageSeverity(specDmg) + player.BodyPoints -= specDmg + if player.BodyPoints < 0 { + player.BodyPoints = 0 + } - playerMsgs = append(playerMsgs, specText) - playerMsgs = append(playerMsgs, fmt.Sprintf(" %s burn to %s. [%d Damage]", severity, part, specDmg)) - roomMsgs = append(roomMsgs, specText) + playerMsgs = append(playerMsgs, specText) + playerMsgs = append(playerMsgs, fmt.Sprintf(" %s burn to %s. [%d Damage]", severity, part, specDmg)) + roomMsgs = append(roomMsgs, specText) - if player.BodyPoints <= 0 { - deathMsgs := e.handlePlayerDeath(player, name) - playerMsgs = append(playerMsgs, deathMsgs...) - return playerMsgs, roomMsgs - } + if player.BodyPoints <= 0 { + deathMsgs := e.handlePlayerDeath(player, name) + playerMsgs = append(playerMsgs, deathMsgs...) + return playerMsgs, roomMsgs + } } // end else (didn't dodge) } @@ -1054,7 +1094,11 @@ func (e *GameEngine) monsterAttackPlayer(inst *MonsterInstance, def *gameworld.M // Weather modifier for monsters too wMod := e.weatherMod(inst.RoomNumber) - defRating := playerDefenseRating(player) + defRating := e.playerDefenseRating(player) + + visionMod := e.visibilityCombatModifier(player, player.RoomNumber) + defRating += visionMod / 2 + // Multi-attacker penalty: -5 per 2 additional attackers beyond the first if e.monsterMgr != nil { attackerCount := 0 @@ -1098,12 +1142,30 @@ func (e *GameEngine) monsterAttackPlayer(inst *MonsterInstance, def *gameworld.M // Monster poison/disease/fatigue on hit if def.PoisonChance > 0 && rand.Intn(100) < def.PoisonChance { player.Poisoned = true + player.ApplyStatEffect(def.Number, + EffectSourcePoison, + StatBodyPoint, + -def.PoisonLevel, + time.Duration(def.PoisonLevel)*time.Minute) playerMsgs = append(playerMsgs, " You feel poison coursing through your veins!") } if def.DiseaseChance > 0 && rand.Intn(100) < def.DiseaseChance { player.Diseased = true - playerMsgs = append(playerMsgs, " You feel a sickness taking hold!") + + player.ApplyStatEffect( + def.Number, + EffectSourceDisease, + StatFatigue, + -def.DiseaseLevel, + time.Duration(def.DiseaseLevel)*time.Minute, + ) + + playerMsgs = append( + playerMsgs, + " You feel a sickness taking hold!", + ) } + if def.FatigueChance > 0 && rand.Intn(100) < def.FatigueChance { drain := def.FatigueLevel if drain <= 0 { @@ -1219,119 +1281,289 @@ func (e *GameEngine) doDepart(player *Player) *CommandResult { return result } -// damageMonster applies damage to a monster instance. Returns true if killed. -func (e *GameEngine) damageMonster(monsterID int, dmg int) bool { +func (e *GameEngine) damageMonster(player *Player, monsterID int, dmg int) bool { + e.monsterMgr.mu.Lock() defer e.monsterMgr.mu.Unlock() + for i := range e.monsterMgr.instances { - if e.monsterMgr.instances[i].ID == monsterID && e.monsterMgr.instances[i].Alive { - e.monsterMgr.instances[i].CurrentHP -= dmg - if e.monsterMgr.instances[i].CurrentHP <= 0 { - e.monsterMgr.instances[i].Alive = false - e.monsterMgr.instances[i].CurrentHP = 0 - e.monsterMgr.instances[i].DeathTime = time.Now() - return true - } - return false + + inst := &e.monsterMgr.instances[i] + + if inst.ID != monsterID || !inst.Alive { + continue + } + + if inst.DamageByPlayer == nil { + inst.DamageByPlayer = make(map[bson.ObjectID]int) + } + + creditedDamage := dmg + if creditedDamage > inst.CurrentHP { + creditedDamage = inst.CurrentHP + } + + if creditedDamage > 0 { + inst.DamageByPlayer[player.ID] += creditedDamage + } + + inst.CurrentHP -= dmg + + if inst.CurrentHP <= 0 { + inst.Alive = false + inst.CurrentHP = 0 + inst.DeathTime = time.Now() + return true } + + return false } + return false } // ---- Monster Death ---- func (e *GameEngine) handleMonsterDeath(killer *Player, inst *MonsterInstance, def *gameworld.MonsterDef) { - // XP formula: Body (not ExtraBody) + Attack/5 + Defense/5 + Armor/2 + level scaling - xp := def.Body + def.Attack1/5 + def.Defense/5 + def.Armor/2 + // Base XP formula: Body (not ExtraBody) + Attack/5 + Defense/5 + Armor/2. + baseXP := def.Body + def.Attack1/5 + def.Defense/5 + def.Armor/2 + if def.MagicResist > 0 { - xp += def.MagicResist / 5 + baseXP += def.MagicResist / 5 } - if xp < 10 { - xp = 10 + + if baseXP < 10 { + baseXP = 10 } - // Scale XP slightly by player level (diminishing returns for grinding weak mobs) - if killer.Level > 1 && xp < killer.Level*5 { - xp = max(5, xp*50/(killer.Level*5)) + + // ------------------------------------------------------------ + // Shared XP based on damage contribution. + // ------------------------------------------------------------ + + totalDamage := 0 + + for _, dmg := range inst.DamageByPlayer { + totalDamage += dmg } - killer.Experience += xp - // Alignment shift - if def.Alignment < 0 { - killer.Alignment += 1 - } else if def.Alignment > 0 { - killer.Alignment -= 1 + // Fallback for anything that somehow killed the monster without + // contribution data. + if totalDamage <= 0 { + inst.DamageByPlayer = make(map[bson.ObjectID]int) + inst.DamageByPlayer[killer.ID] = 1 + totalDamage = 1 } - e.Events.Publish("combat", fmt.Sprintf("%s killed %s (monster %d) for %d XP in room %d", - killer.FirstName, def.Name, def.Number, xp, killer.RoomNumber)) + for playerID, damage := range inst.DamageByPlayer { + if damage <= 0 { + continue + } + + var player *Player - // Drop monster's weapon into the room as loot (skip natural weapons like claws/teeth/fists) + // Find the contributing player. + if e.sessions != nil { + for _, p := range e.sessions.OnlinePlayers() { + if p.ID == playerID { + player = p + break + } + } + } + + // For now, only online players receive XP. + if player == nil { + continue + } + + // Player's proportional share of the monster's XP. + xp := baseXP * damage / totalDamage + + if xp < 1 { + xp = 1 + } + + // Apply diminishing returns individually based on each + // player's level. + if player.Level > 1 && xp < player.Level*5 { + xp = max(5, xp*50/(player.Level*5)) + } + + player.Experience += xp + + // Alignment shift. + if def.Alignment < 0 { + player.Alignment += 1 + } else if def.Alignment > 0 { + player.Alignment -= 1 + } + + // Recalculate build points and check for level-up. + oldLevel := player.Level + oldBP := player.BuildPoints + + leveledUp := recalcBuildPoints(player) + + newBP := player.BuildPoints + + var xpMsgs []string + + percent := damage * 100 / totalDamage + + xpMsgs = append( + xpMsgs, + fmt.Sprintf( + "[+%d experience (%d%% damage)]", + xp, + percent, + ), + ) + + if newBP > oldBP { + xpMsgs = append( + xpMsgs, + fmt.Sprintf( + "[+%d build points! Total: %d]", + newBP-oldBP, + newBP, + ), + ) + } + + if leveledUp { + player.MaxBodyPoints += player.Constitution / 10 + player.BodyPoints = player.MaxBodyPoints + + player.MaxFatigue += player.Constitution / 15 + player.Fatigue = player.MaxFatigue + + player.MaxMana += (player.Willpower + player.Empathy) / 15 + player.Mana = player.MaxMana + + player.MaxPsi += player.Willpower / 10 + player.Psi = player.MaxPsi + + xpMsgs = append( + xpMsgs, + fmt.Sprintf( + "Congratulations! You have advanced to level %d!", + player.Level, + ), + ) + + if e.roomBroadcast != nil { + e.roomBroadcast( + player.RoomNumber, + []string{ + fmt.Sprintf( + "%s has advanced to level %d!", + player.FirstName, + player.Level, + ), + }, + ) + } + + _ = oldLevel + } + + if e.sendToPlayer != nil { + e.sendToPlayer( + player.FirstName, + xpMsgs, + ) + } + + e.Events.Publish( + "combat", + fmt.Sprintf( + "%s received %d XP for %d damage to %s (monster %d) in room %d", + player.FirstName, + xp, + damage, + def.Name, + def.Number, + player.RoomNumber, + ), + ) + } + + // ------------------------------------------------------------ + // Loot is still based on where the monster died. + // ------------------------------------------------------------ + + roomNumber := killer.RoomNumber + + // Drop monster's weapon into the room as loot + // (skip natural weapons like claws/teeth/fists). if len(def.Weapons) > 0 && !def.Discorporate { - room := e.rooms[killer.RoomNumber] + room := e.rooms[roomNumber] + if room != nil { wep := def.Weapons[rand.Intn(len(def.Weapons))] wepDef := e.items[wep.Archetype] + if wepDef != nil && !isNaturalWeapon(wepDef.Type) { ref := len(room.Items) + ri := gameworld.RoomItem{ Ref: ref, Archetype: wep.Archetype, Adj1: wep.Adj, } + if def.WeaponPlus > 0 { ri.Val2 = def.WeaponPlus } - room.Items = append(room.Items, ri) - wepName := e.formatItemName(wepDef, wep.Adj, 0, 0) + + room.Items = append( + room.Items, + ri, + ) + + wepName := e.formatItemName( + wepDef, + wep.Adj, + 0, + 0, + ) + if e.localRoomBroadcast != nil { - article := articleFor(wepName, false) - e.localRoomBroadcast(killer.RoomNumber, []string{fmt.Sprintf("%s%s clatters to the ground.", capArticle(article), wepName)}) + article := articleFor( + wepName, + false, + ) + + e.localRoomBroadcast( + roomNumber, + []string{ + fmt.Sprintf( + "%s%s clatters to the ground.", + capArticle(article), + wepName, + ), + }, + ) } } } } - // Generate treasure drops based on monster's TREASURE level + // Generate treasure drops based on monster's TREASURE level. if def.Treasure > 0 && !def.Discorporate { - treasureMsgs := e.generateTreasure(killer.RoomNumber, def.Treasure) - if len(treasureMsgs) > 0 && e.localRoomBroadcast != nil { - e.localRoomBroadcast(killer.RoomNumber, treasureMsgs) - } - } + treasureMsgs := e.generateTreasure( + roomNumber, + def.Treasure, + ) - // Recalculate build points and check for level-up - oldLevel := killer.Level - oldBP := killer.BuildPoints - leveledUp := recalcBuildPoints(killer) - newBP := killer.BuildPoints + if len(treasureMsgs) > 0 && + e.localRoomBroadcast != nil { - // Tell the player - var xpMsgs []string - xpMsgs = append(xpMsgs, fmt.Sprintf("[+%d experience]", xp)) - if newBP > oldBP { - xpMsgs = append(xpMsgs, fmt.Sprintf("[+%d build points! Total: %d]", newBP-oldBP, newBP)) - } - - if leveledUp { - killer.MaxBodyPoints += killer.Constitution / 10 - killer.BodyPoints = killer.MaxBodyPoints - killer.MaxFatigue += killer.Constitution / 15 - killer.Fatigue = killer.MaxFatigue - killer.MaxMana += (killer.Willpower + killer.Empathy) / 15 - killer.Mana = killer.MaxMana - killer.MaxPsi += killer.Willpower / 10 - killer.Psi = killer.MaxPsi - xpMsgs = append(xpMsgs, fmt.Sprintf("Congratulations! You have advanced to level %d!", killer.Level)) - if e.roomBroadcast != nil { - e.roomBroadcast(killer.RoomNumber, []string{ - fmt.Sprintf("%s has advanced to level %d!", killer.FirstName, killer.Level), - }) + e.localRoomBroadcast( + roomNumber, + treasureMsgs, + ) } - _ = oldLevel - } - - if e.sendToPlayer != nil { - e.sendToPlayer(killer.FirstName, xpMsgs) } } @@ -1359,15 +1591,24 @@ func (e *GameEngine) doFlee(ctx context.Context, player *Player) *CommandResult } var exits []exitInfo for dir, dest := range room.Exits { + + upperDir := strings.ToUpper(dir) + + // Don't flee upward unless the player can fly. + if (upperDir == "U" || upperDir == "UP" || upperDir == "ABOVE") && !player.CanFly { + continue + } + if dest > 0 { exits = append(exits, exitInfo{dir, dest}) } } + if len(exits) == 0 { return &CommandResult{Messages: []string{"There is nowhere to flee!"}} } - fleeChance := 50 + player.Quickness/5 + player.Agility/10 + fleeChance := 50 + player.EffectiveStat(StatQuickness)/5 + player.EffectiveStat(StatAgility)/10 if player.Position != 0 { fleeChance -= 20 } @@ -1658,6 +1899,11 @@ func (e *GameEngine) monsterFlee(inst *MonsterInstance, def *gameworld.MonsterDe var exits []exitInfo for dir, dest := range room.Exits { if dest > 0 { + //only avians can fly? Dont see a fly setting so sticking with that for now. If we add a fly setting to monsters, we can change this to check that instead of body type. + upperDir := strings.ToUpper(dir) + if (upperDir == "U" || upperDir == "UP" || upperDir == "ABOVE") && def.BodyType != "AVINE" { + continue + } exits = append(exits, exitInfo{dir, dest}) } } @@ -1680,6 +1926,20 @@ func (e *GameEngine) monsterFlee(inst *MonsterInstance, def *gameworld.MonsterDe } inst.Target = "" + + // Disengage any players fighting this monster. + if e.sessions != nil { + for _, player := range e.sessions.OnlinePlayers() { + if player.RoomNumber != inst.RoomNumber { + continue + } + + if player.CombatTarget != nil && player.CombatTarget.MonsterID == inst.ID { + player.Joined = false + player.CombatTarget = nil + } + } + } e.monsterMgr.moveMonster(e.monsterMgr.indexOfID(inst.ID), chosen.destID) } diff --git a/engine/internal/engine/crafting.go b/engine/internal/engine/crafting.go index eda6eb3..11769c7 100644 --- a/engine/internal/engine/crafting.go +++ b/engine/internal/engine/crafting.go @@ -63,7 +63,7 @@ func (e *GameEngine) doMineReal(ctx context.Context, player *Player) *CommandRes } // Success chance: base 30% + mining*5 + STR/10 - chance := 30 + miningSkill*5 + player.Strength/10 + chance := 30 + miningSkill*5 + player.EffectiveStat(StatStrength)/10 if chance > 90 { chance = 90 } @@ -478,35 +478,34 @@ func (e *GameEngine) doWork(ctx context.Context, player *Player, args []string) // Find matching material in inventory materialIdx := -1 materialAdj := 0 + + // Search inventory for the material for j, ii := range player.Inventory { mDef := e.items[ii.Archetype] if mDef == nil { continue } - if mDef.Type == "MATERIAL" || mDef.Type == "MATERIAL2" { - if mDef.Parameter2 == 8 || mDef.Parameter2 == 0 { // weaponsmithing material - mName := strings.ToLower(e.getItemNounName(mDef)) - // Check both definition adjective and instance adjective - defAdj := "" - if mDef.Parameter1 > 0 { - defAdj = strings.ToLower(e.getAdjName(mDef.Parameter1)) - } - instAdj := strings.ToLower(e.getAdjName(ii.Adj1)) - if strings.Contains(mName, metal) || strings.Contains(defAdj, metal) || - strings.Contains(instAdj, metal) || strings.HasPrefix(metal, defAdj) || - strings.HasPrefix(metal, instAdj) { - materialIdx = j - if ii.Adj1 > 0 { - materialAdj = ii.Adj1 // prefer instance adjective - } else if mDef.Parameter1 > 0 { - materialAdj = mDef.Parameter1 - } - break - } + + // Check if it's a material for Weaponsmithing (Skill 8) + if (mDef.Type == "MATERIAL" || mDef.Type == "MATERIAL2" || mDef.Type == "MISC") && (mDef.Parameter2 == 8 || mDef.Parameter2 == 0) { + + // Get the names for comparison + mNoun := strings.ToLower(e.getItemNounName(mDef)) // e.g., "metal" + instAdj := strings.ToLower(e.getAdjName(ii.Adj1)) // e.g., "copper" + + // FLEXIBLE MATCHING: + // Check if the user's input matches the adjective, the noun, or the combined name + fullItemName := instAdj + " " + mNoun // e.g., "copper metal" + + if metal == instAdj || metal == mNoun || metal == fullItemName || + strings.Contains(fullItemName, metal) { + + materialIdx = j + materialAdj = ii.Adj1 + break } } } - if materialIdx < 0 { // Also accept the metal name directly as a known metal type knownMetals := []string{"copper", "iron", "brass", "bronze", "steel", "truesteel", "randar", "elkyri"} @@ -556,7 +555,6 @@ func (e *GameEngine) doWork(ctx context.Context, player *Player, args []string) RoomBroadcast: []string{fmt.Sprintf("%s works diligently at the forge.", player.FirstName)}, PlayerState: player, } - case 2: // Heated → Hammer player.CraftingStep = 3 player.RoundTimeExpiry = time.Now().Add(15 * time.Second) diff --git a/engine/internal/engine/engine.go b/engine/internal/engine/engine.go index 6e81685..89e5729 100644 --- a/engine/internal/engine/engine.go +++ b/engine/internal/engine/engine.go @@ -20,6 +20,9 @@ import ( "go.mongodb.org/mongo-driver/v2/mongo/options" ) +// MaxBankItems is the maximum number of items a player can store in their bank. +const MaxBankItems = 25 + var validNamePattern = regexp.MustCompile(`^[a-zA-Z][a-zA-Z'-]{2,19}$`) // min 3 chars total // reservedExactNames are blocked as whole-word matches only. @@ -45,6 +48,15 @@ var reservedSubstrings = []string{ "nazi", "hitler", } +type VisibilityLevel int + +const ( + VisibilityDark VisibilityLevel = iota + VisibilityPartial + VisibilityLimited + VisibilityClear +) + // ValidateCharacterInput checks character creation parameters. func ValidateCharacterInput(firstName, lastName string, race, gender int) error { if !validNamePattern.MatchString(firstName) { @@ -110,36 +122,36 @@ type PlayerMessageFunc func(playerName string, messages []string) // GameEngine holds the loaded game world and processes commands. type GameEngine struct { - db *mongo.Database - nouns map[int]string - adjectives map[int]string - monAdjs map[int]string - items map[int]*gameworld.ItemDef - rooms map[int]*gameworld.Room - monsters map[int]*gameworld.MonsterDef - startRoom int - departRoom int // safe room for DEPART (bump room) - sessions SessionProvider - onRoomChange RoomChangeCallback - roomBroadcast RoomBroadcastFunc - localRoomBroadcast LocalRoomBroadcastFunc - sendToPlayer PlayerMessageFunc - monsterMgr *monsterManager - RegionWeather map[int]int // region -> weather state - monsterLists []gameworld.MonsterList // base + current season MLISTs - baseMonsterLists []gameworld.MonsterList // always-loaded MLISTs + db *mongo.Database + nouns map[int]string + adjectives map[int]string + monAdjs map[int]string + items map[int]*gameworld.ItemDef + rooms map[int]*gameworld.Room + monsters map[int]*gameworld.MonsterDef + startRoom int + departRoom int // safe room for DEPART (bump room) + sessions SessionProvider + onRoomChange RoomChangeCallback + roomBroadcast RoomBroadcastFunc + localRoomBroadcast LocalRoomBroadcastFunc + sendToPlayer PlayerMessageFunc + monsterMgr *monsterManager + RegionWeather map[int]int // region -> weather state + monsterLists []gameworld.MonsterList // base + current season MLISTs + baseMonsterLists []gameworld.MonsterList // always-loaded MLISTs seasonalMonsterLists map[string][]gameworld.MonsterList // per-season MLISTs seasonalRooms map[string][]gameworld.Room // per-season room overrides currentSeason string // current active season key - cevents []gameworld.CEvent - forageDefs []gameworld.ForageDef - PVals map[int]int // persistent global values - NamedVars map[string]int // VARIABLE-defined global named variables (DANWATER, etc.) - namedVarNames map[string]bool // set of valid named variable names - Events *EventBus - Banner string // active login banner; in-memory so it works even if MongoDB is down - lastAssistName string // last player who used ASSIST (for @answer) - lastAssistRoom int // room number of last ASSIST + cevents []gameworld.CEvent + forageDefs []gameworld.ForageDef + PVals map[int]int // persistent global values + NamedVars map[string]int // VARIABLE-defined global named variables (DANWATER, etc.) + namedVarNames map[string]bool // set of valid named variable names + Events *EventBus + Banner string // active login banner; in-memory so it works even if MongoDB is down + lastAssistName string // last player who used ASSIST (for @answer) + lastAssistRoom int // room number of last ASSIST } // SetSessionProvider sets the session provider (called by API layer after init). @@ -212,16 +224,16 @@ func (e *GameEngine) saveBanner(text string) { // GMScript represents a GM-uploaded script stored in MongoDB. type GMScript struct { - Filename string `bson:"_id" json:"filename"` - Name string `bson:"name" json:"name"` - Content string `bson:"content" json:"content"` - Priority int `bson:"priority" json:"priority"` // higher = loads sooner - Size int `bson:"size" json:"size"` - UploadedBy string `bson:"uploadedBy" json:"uploadedBy"` - UploadedByAccountID string `bson:"uploadedByAccountId" json:"uploadedByAccountId"` - UploadedAt time.Time `bson:"uploadedAt" json:"uploadedAt"` - ParseStats ScriptApplyStats `bson:"parseStats" json:"parseStats"` - History []GMScriptVersion `bson:"history" json:"history"` + Filename string `bson:"_id" json:"filename"` + Name string `bson:"name" json:"name"` + Content string `bson:"content" json:"content"` + Priority int `bson:"priority" json:"priority"` // higher = loads sooner + Size int `bson:"size" json:"size"` + UploadedBy string `bson:"uploadedBy" json:"uploadedBy"` + UploadedByAccountID string `bson:"uploadedByAccountId" json:"uploadedByAccountId"` + UploadedAt time.Time `bson:"uploadedAt" json:"uploadedAt"` + ParseStats ScriptApplyStats `bson:"parseStats" json:"parseStats"` + History []GMScriptVersion `bson:"history" json:"history"` } // GMScriptVersion is a historical version of a script. @@ -357,10 +369,10 @@ func (e *GameEngine) parseAndApplyScript(content, filename string) (ScriptApplyS // ScriptApplyStats summarizes what a hot-loaded script changed. type ScriptApplyStats struct { - Rooms int `json:"rooms"` - Items int `json:"items"` - Monsters int `json:"monsters"` - Nouns int `json:"nouns"` + Rooms int `json:"rooms"` + Items int `json:"items"` + Monsters int `json:"monsters"` + Nouns int `json:"nouns"` Variables int `json:"variables"` } @@ -596,6 +608,18 @@ func (e *GameEngine) StartCEventLoop() { defer ticker.Stop() for range ticker.C { tick++ + + // Process player timers/status effects. + if e.sessions != nil { + for _, player := range e.sessions.OnlinePlayers() { + messages := e.UpdatePlayerTimers(player) + + if len(messages) > 0 && e.sendToPlayer != nil { + e.sendToPlayer(player.FirstName, messages) + } + } + } + for _, ce := range e.cevents { if ce.Cycles > 0 && tick%ce.Cycles == 0 { room := e.rooms[ce.Room] @@ -663,7 +687,7 @@ type CommandResult struct { CantMsg string `json:"-"` CantSender string `json:"-"` // LogEvent: optional event to log (type, detail). - LogEventType string `json:"-"` + LogEventType string `json:"-"` LogEventDetail string `json:"-"` } @@ -894,11 +918,19 @@ func (e *GameEngine) ProcessCommand(ctx context.Context, player *Player, input s case "CLIMB": return e.doClimb(ctx, player, args) case "GET", "TAKE": - return e.doGet(ctx, player, args) + result := e.doGet(ctx, player, args) + e.RefreshEncumbrance(player) + return result + case "DOATEST": + return e.doATest(ctx, player) case "DROP": - return e.doDrop(ctx, player, args) + result := e.doDrop(ctx, player, args) + e.RefreshEncumbrance(player) + return result case "INVENTORY": - return e.doInventory(player) + result := e.doInventory(player) + e.RefreshEncumbrance(player) + return result case "STATUS": if len(args) > 0 { t := strings.ToLower(strings.Join(args, " ")) @@ -926,13 +958,21 @@ func (e *GameEngine) ProcessCommand(ctx context.Context, player *Player, input s } return e.doHealth(player) case "WIELD": - return e.doWield(ctx, player, args) + result := e.doWield(ctx, player, args) + e.RefreshEncumbrance(player) + return result case "UNWIELD": - return e.doUnwield(ctx, player) + result := e.doUnwield(ctx, player, args) + e.RefreshEncumbrance(player) + return result case "WEAR": - return e.doWear(ctx, player, args) + result := e.doWear(ctx, player, args) + e.RefreshEncumbrance(player) + return result case "REMOVE": - return e.doRemove(ctx, player, args) + result := e.doRemove(ctx, player, args) + e.RefreshEncumbrance(player) + return result case "OPEN": return e.doOpen(player, args) case "CLOSE": @@ -985,7 +1025,9 @@ func (e *GameEngine) ProcessCommand(ctx context.Context, player *Player, input s lvl := player.Skills[id] if lvl > 0 { name := SkillNames[id] - if name == "" { name = fmt.Sprintf("Skill #%d", id) } + if name == "" { + name = fmt.Sprintf("Skill #%d", id) + } skillMsgs = append(skillMsgs, fmt.Sprintf(" %s: rank %d", name, lvl)) hasSkills = true } @@ -1014,14 +1056,22 @@ func (e *GameEngine) ProcessCommand(ctx context.Context, player *Player, input s spentBP := playerBPSpent(player) totalBP := player.BuildPoints + spentBP xpUntilNext := xpUntilNextBuildPoint(player) + + // Build points + // totalBP := player.BuildPoints + // spentBP := playerBPSpent(player) + unspentBP := totalBP - spentBP + return &CommandResult{Messages: []string{ fmt.Sprintf("Experience: %d", player.Experience), fmt.Sprintf("Build Points to date: %d", totalBP), - fmt.Sprintf("Unspent Build Points: %d", player.BuildPoints), + fmt.Sprintf("Unspent Build Points: %d", unspentBP), fmt.Sprintf("Experience Points until next Build Point: %d", xpUntilNext), }} case "INFO": return e.doInfo(player) + case "REROLL": + return e.doRerollStats(ctx, player, args) case "TIME": period := "day" if IsNight() { @@ -1042,9 +1092,15 @@ func (e *GameEngine) ProcessCommand(ctx context.Context, player *Player, input s case "YELL": return e.doYell(player, args, input) case "GIVE": - return e.doGive(ctx, player, args) + result := e.doGive(ctx, player, args) + e.RefreshEncumbrance(player) + return result + case "PICK", "LOCKPICK": + return e.doPick(ctx, player, args) case "EAT": - return e.doEat(ctx, player, args) + result := e.doEat(ctx, player, args) + e.RefreshEncumbrance(player) + return result case "SPEECH": return &CommandResult{Messages: []string{"Speech patterns are set by gamemasters. Ask a GM if you'd like a custom speech style."}} case "QUIT": @@ -1150,7 +1206,7 @@ func (e *GameEngine) ProcessCommand(ctx context.Context, player *Player, input s "HULA", "JIG", "MOAN", "MASSAGE", "PINCH", "PURR", "ROAR", "SNARL", "SNUGGLE", "WAG", "WAIT", "WRITE", "YOWL", "STOMP", "APPLAUD", "PEER", "GRUNT", "DIP", - "HANDRAISE", "HANDSHAKE", "HEADSHAKE", "PICK", "GESTURE", + "HANDRAISE", "HANDSHAKE", "HEADSHAKE", "GESTURE", // Additional self-emotes "FUME", "SQUINT", "HUM", "SNIFFLE", "SLOUCH", "SNORE", "SNEEZE", "STARE", "PUCKER", "CRACK", "BOUNCE", "STRIKE", "CLUTCH", @@ -1159,6 +1215,10 @@ func (e *GameEngine) ProcessCommand(ctx context.Context, player *Player, input s // Race-specific emotes (handled by race check in processEmote) "FLICK", "BARE", "SPREAD", "FOLD", "SWISH", "RUBEARS", "PULLBEARD", "SCENT", "WHINE", "DROOP", "CHASE": + + if len(args) > 0 { + return e.doItemInteraction(ctx, player, verb, args) + } return e.processEmote(player, verb, args) case "ACT": if len(args) == 0 { @@ -1203,7 +1263,9 @@ func (e *GameEngine) ProcessCommand(ctx context.Context, player *Player, input s var selfMsgs, roomMsgs []string for i, line := range lines { line = strings.TrimSpace(line) - if line == "" { continue } + if line == "" { + continue + } if i == 0 { selfMsgs = append(selfMsgs, fmt.Sprintf("You recite, '%s", line)) roomMsgs = append(roomMsgs, fmt.Sprintf("%s recites, '%s", player.FirstName, line)) @@ -1230,7 +1292,7 @@ func (e *GameEngine) ProcessCommand(ctx context.Context, player *Player, input s // Bare SEARCH: scan the area for hidden players player.RoundTimeExpiry = time.Now().Add(5 * time.Second) msgs := []string{"You search the area.", "[Round: 5 sec]"} - perceptionCheck := player.Perception + player.Skills[33]*5 // Stealth skill helps detection + perceptionCheck := player.EffectiveStat(StatPerception) + player.Skills[33]*5 // Stealth skill helps detection var revealed []string if e.sessions != nil { for _, p := range e.sessions.OnlinePlayers() { @@ -1334,19 +1396,25 @@ func (e *GameEngine) ProcessCommand(ctx context.Context, player *Player, input s case "FLY": return e.doFly(ctx, player) case "ASCEND": - if player.Position != 4 { return &CommandResult{Messages: []string{"You must be flying to ascend."}} } + if player.Position != 4 { + return &CommandResult{Messages: []string{"You must be flying to ascend."}} + } return e.doMove(ctx, player, "U") case "DESCEND": - if player.Position != 4 { return &CommandResult{Messages: []string{"You must be flying to descend."}} } + if player.Position != 4 { + return &CommandResult{Messages: []string{"You must be flying to descend."}} + } return e.doMove(ctx, player, "D") case "LAND": - if player.Position != 4 { return &CommandResult{Messages: []string{"You aren't flying."}} } + if player.Position != 4 { + return &CommandResult{Messages: []string{"You aren't flying."}} + } player.Position = 0 e.SavePlayer(ctx, player) return &CommandResult{Messages: []string{"You land."}, RoomBroadcast: []string{fmt.Sprintf("%s lands.", player.FirstName)}} // === ITEM INTERACTION === case "PUT", "PLACE": - return &CommandResult{Messages: []string{"[PUT system coming soon.]"}} // TODO: implement item placement + return e.doPut(ctx, player, args) case "FILL": return e.doFill(ctx, player, args) case "MARK": @@ -1361,7 +1429,8 @@ func (e *GameEngine) ProcessCommand(ctx context.Context, player *Player, input s case "SPELL": return e.doSpellList(player) case "UNPROMPT": - player.PromptMode = false; e.SavePlayer(ctx, player) + player.PromptMode = false + e.SavePlayer(ctx, player) return &CommandResult{Messages: []string{"Prompt indicators off."}} case "VERSION", "NEWS", "NOTES": return &CommandResult{Messages: []string{"Legends of Future Past v11.5.11"}} @@ -1531,6 +1600,8 @@ func (e *GameEngine) ProcessCommand(ctx context.Context, player *Player, input s return &CommandResult{Messages: []string{"[Self-training coming soon.]"}} // TODO: train self at +1 cost case "UNLEARN": return e.doUnlearn(ctx, player, args) + case "LEARN": + return e.doLearn(ctx, player, args) case "ANOINT": return e.doAnoint(ctx, player, args) case "TRAP": @@ -1639,7 +1710,9 @@ func (e *GameEngine) ProcessCommand(ctx context.Context, player *Player, input s reportText := strings.Join(strings.Fields(input)[1:], " ") room := e.rooms[player.RoomNumber] roomName := "unknown" - if room != nil { roomName = room.Name } + if room != nil { + roomName = room.Name + } e.Events.Publish("report", fmt.Sprintf("[REPORT] %s (room %d %s): %s", player.FirstName, player.RoomNumber, roomName, reportText)) return &CommandResult{ Messages: []string{"Your report has been filed. Thank you!"}, @@ -1659,21 +1732,208 @@ func (e *GameEngine) ProcessCommand(ctx context.Context, player *Player, input s return e.doSet(ctx, player, []string{"RPBRIEF"}) case "SET": return e.doSet(ctx, player, args) + case "REPORTS": + return e.doReports(ctx, player, args) + case "REPORTCOMPLETE": + return e.doReportComplete(ctx, player, args) case "SNIFF", "SMELL": if len(args) > 0 { - return e.doItemInteraction(ctx, player, "SNIFF", args) + return e.doItemInteraction(ctx, player, "SMELL", args) } - return e.processEmote(player, "SNIFF", args) + return e.processEmote(player, verb, args) case "LISTEN": if len(args) > 0 { return e.doItemInteraction(ctx, player, "LISTEN", args) } + + // Check for room-level IFVERB LISTEN -1 first. + if result := e.doRoomScriptVerb(ctx, player, "LISTEN"); result != nil { + return result + } + + // No room script, use normal emote. return e.processEmote(player, "LISTEN", args) default: + // Run room-level IFVERB -1 scripts first. + // Example: IFVERB PAY -1 + if result := e.doRoomScriptVerb(ctx, player, verb); result != nil { + return result + } return &CommandResult{Messages: []string{fmt.Sprintf("I don't understand \"%s\". Type HELP for commands.", strings.ToLower(input))}} } } +func (e *GameEngine) UpdatePlayerTimers(player *Player) []string { + var messages []string + + messages = append(messages, e.ProcessPeriodicStatEffects(player)...) + messages = append(messages, e.RemoveExpiredStatEffects(player)...) + + // Later: + // messages = append(messages, e.RemoveExpiredResistances(player)...) + // messages = append(messages, e.RemoveExpiredConditions(player)...) + + return messages +} + +func (e *GameEngine) ProcessPeriodicStatEffects(player *Player) []string { + now := time.Now() + + if len(player.ActiveStatEffects) == 0 { + return nil + } + + var messages []string + active := player.ActiveStatEffects[:0] + + for _, effect := range player.ActiveStatEffects { + + if effect.Source != EffectSourcePoison && + effect.Source != EffectSourceDisease { + active = append(active, effect) + continue + } + + if effect.ExpiresAt.After(now) { + active = append(active, effect) + continue + } + + switch effect.Source { + case EffectSourcePoison: + damage := -effect.Modifier + + if damage > 0 { + player.BodyPoints -= damage + if player.BodyPoints < 0 { + player.BodyPoints = 0 + } + + messages = append( + messages, + fmt.Sprintf( + "Poison burns in your veins. [%d Damage]", + damage, + ), + ) + } + + case EffectSourceDisease: + drain := -effect.Modifier + + if drain > 0 { + player.Fatigue -= drain + if player.Fatigue < 0 { + player.Fatigue = 0 + } + + messages = append( + messages, + fmt.Sprintf( + "Disease saps your strength. [%d Fatigue]", + drain, + ), + ) + } + } + + effect.Modifier++ // -5 -> -4 + + if effect.Modifier >= 0 { + switch effect.Source { + case EffectSourcePoison: + player.Poisoned = false + messages = append( + messages, + "The poison finally leaves your system.", + ) + + case EffectSourceDisease: + player.Diseased = false + messages = append( + messages, + "You finally recover from the disease.", + ) + } + + continue + } + + effect.ExpiresAt = now.Add(time.Minute) + active = append(active, effect) + } + + player.ActiveStatEffects = active + + return messages +} + +func (e *GameEngine) RemoveExpiredStatEffects(player *Player) []string { + now := time.Now() + + if len(player.ActiveStatEffects) == 0 { + return nil + } + + var messages []string + active := player.ActiveStatEffects[:0] + + for _, effect := range player.ActiveStatEffects { + + // Periodic effects are handled elsewhere. + if effect.Source == EffectSourcePoison || + effect.Source == EffectSourceDisease { + active = append(active, effect) + continue + } + + // No expiration = persistent effect. + if effect.Permanent { + active = append(active, effect) + continue + } + + if effect.ExpiresAt.After(now) { + active = append(active, effect) + continue + } + + switch effect.Source { + case EffectSourceSpell: + if spell := FindSpellByID(effect.EffectID); spell != nil { + messages = append( + messages, + fmt.Sprintf( + "The effects of %s wear off.", + spell.Name, + ), + ) + } else { + messages = append( + messages, + "A magical effect wears off.", + ) + } + + case EffectSourcePotion: + messages = append( + messages, + "The effects of a potion wear off.", + ) + + default: + messages = append( + messages, + "An effect wears off.", + ) + } + } + + player.ActiveStatEffects = active + + return messages +} + // allVerbs is the canonical list of all recognized command verbs. // Abbreviation resolution matches against this list. var allVerbs = []string{ @@ -1689,11 +1949,11 @@ var allVerbs = []string{ "FLIP", "LATCH", "UNLATCH", "DEPOSIT", "WITHDRAW", "TRAIN", "MINE", "FORAGE", - "CRAFT", "FORGE", "SMELT", "WEAVE", "DYE", "BREW", "ANALYZE", "WORK", "REPAIR", + "CRAFT", "FORGE", "SMELT", "WEAVE", "DYE", "BREW", "ANALYZE", "WORK", "REPAIR", "LEARN", // Movement/stealth "HIDE", "SNEAK", "FLY", "ASCEND", "DESCEND", "LAND", // Interaction - "PUT", "PLACE", "FILL", "MARK", "UNDRESS", "SKIN", + "PUT", "PLACE", "FILL", "MARK", "UNDRESS", "SKIN", "PAY", // Info "BALANCE", "SPELL", "BRIEF", "FULL", "PROMPT", "UNPROMPT", "VERSION", "CREDITS", // Communication @@ -1706,7 +1966,7 @@ var allVerbs = []string{ "NOCK", "LOAD", "SPECIALIZE", // Skill-based (TODO: implement) "DISARM", "STEAL", "FILCH", "ROB", "STALK", - "TEACH", "SELFTRAIN", "UNLEARN", + "TEACH", "SELFTRAIN", "UNLEARN", "LEARN", "ANOINT", "POISON", "TRAP", "SURVEY", "SPLIT", // Racial (TODO: implement) @@ -1764,7 +2024,7 @@ var verbAliases = map[string]string{ "INV": "INVENTORY", "STAT": "STATUS", "UNUSE": "UNWIELD", "DON": "WEAR", "EXIT": "QUIT", "SKILL": "SKILLS", "WHI": "WHISPER", "THIN": "THINK", "CONTA": "CONTACT", - "DI": "DIAGNOSE", + "DI": "DIAGNOSE", "ORDER": "BUY", "UNLIGHT": "EXTINGUISH", "IGNITE": "LIGHT", "QUAFF": "DRINK", "SHOUT": "YELL", "A": "ATTACK", "PLACE": "PUT", "TRANS": "TRANSFORM", @@ -1805,6 +2065,10 @@ func (e *GameEngine) doMove(ctx context.Context, player *Player, dir string) *Co if player.Immobilized { return &CommandResult{Messages: []string{"You are immobilized and cannot move!"}} } + + if player.CombatTarget != nil { + return &CommandResult{Messages: []string{"You are engaged in combat! Try FLEE."}} + } // Normal movement reveals hidden players (but not Ethereal Projection — that's psi-maintained) if player.Hidden && !player.EtherealActive { player.Hidden = false @@ -1861,7 +2125,8 @@ func (e *GameEngine) doMove(ctx context.Context, player *Player, dir string) *Co player.RoomNumber = destNum player.Submitting = false // moving clears submit state - e.disengageCombat(player) // moving clears combat + + //e.disengageCombat(player) // moving clears combat // Moving away from leader breaks follow if player.Following != "" { @@ -2068,7 +2333,7 @@ func (e *GameEngine) doLookFull(player *Player) *CommandResult { } result := e.doLook(player) // Always include the full description regardless of BriefMode - if !player.Dead && result.RoomDesc == "" { + if !player.Dead && e.canPlayerSee(player, room) && result.RoomDesc == "" { result.RoomDesc = room.Description } return result @@ -2092,12 +2357,21 @@ func (e *GameEngine) doLook(player *Player) *CommandResult { return result } + if !e.canPlayerSee(player, room) { + result.Messages = []string{"It is too dark to see."} + return result + } + if !player.BriefMode { result.RoomDesc = room.Description } // List visible items for _, ri := range room.Items { + + if ri.IsPut { + continue + } // Coin piles if ri.State == "MONEY" { result.Items = append(result.Items, "some coins") @@ -2234,146 +2508,414 @@ func (e *GameEngine) doLookAt(player *Player, args []string) *CommandResult { target := strings.ToLower(strings.Join(args, " ")) + room := e.rooms[player.RoomNumber] + if room == nil { + return &CommandResult{ + Messages: []string{"You see nothing."}, + } + } + // Check for directional look (LOOK N, LOOK NORTH, etc.) if dir, ok := lookDirMap[target]; ok { - room := e.rooms[player.RoomNumber] - if room != nil { - destNum, hasExit := room.Exits[dir] - if !hasExit && dir == "U" { - destNum, hasExit = room.Exits["ABOVE"] - } - if !hasExit && dir == "D" { - destNum, hasExit = room.Exits["BELOW"] - } - if hasExit { - if dest := e.rooms[destNum]; dest != nil { - msgs := []string{fmt.Sprintf("[%s]", dest.Name)} - if dest.Description != "" { - msgs = append(msgs, descriptionToMessages(dest.Description)...) - } - // Show players in that room - if e.sessions != nil { - var playersHere []string - for _, p := range e.sessions.OnlinePlayers() { - if p.RoomNumber == destNum && !p.Hidden && !p.Invisible && !p.GMInvis { - playersHere = append(playersHere, p.FirstName) - } - } - if len(playersHere) > 0 { - msgs = append(msgs, fmt.Sprintf("You see %s.", strings.Join(playersHere, ", "))) - } - } - // Show room items - for _, ri := range dest.Items { - itemDef := e.items[ri.Archetype] - if itemDef == nil { - continue - } - itemName := e.formatItemName(itemDef, ri.Adj1, ri.Adj2, ri.Adj3) - msgs = append(msgs, fmt.Sprintf("You see %s.", itemName)) - } - // Show monsters - monLines := e.MonsterLookLines(destNum) - msgs = append(msgs, monLines...) - return &CommandResult{Messages: msgs} + destNum, hasExit := room.Exits[dir] + + if !hasExit && dir == "U" { + destNum, hasExit = room.Exits["ABOVE"] + } + + if !hasExit && dir == "D" { + destNum, hasExit = room.Exits["BELOW"] + } + + if !hasExit { + return &CommandResult{ + Messages: []string{ + "You see nothing of interest in that direction.", + }, + } + } + + dest := e.rooms[destNum] + if dest == nil { + return &CommandResult{ + Messages: []string{ + "You see nothing of interest in that direction.", + }, + } + } + + // Directional look uses the visibility of the DESTINATION room. + if e.roomVisibility(player, dest) == VisibilityDark { + return &CommandResult{ + Messages: []string{ + "It is too dark to see in that direction.", + }, + } + } + + msgs := []string{ + fmt.Sprintf("[%s]", dest.Name), + } + + if dest.Description != "" { + msgs = append( + msgs, + descriptionToMessages(dest.Description)..., + ) + } + + // Show players in that room. + if e.sessions != nil { + var playersHere []string + + for _, p := range e.sessions.OnlinePlayers() { + if p.RoomNumber == destNum && + !p.Hidden && + !p.Invisible && + !p.GMInvis { + + playersHere = append(playersHere, p.FirstName) } } - return &CommandResult{Messages: []string{"You see nothing of interest in that direction."}} + + if len(playersHere) > 0 { + msgs = append( + msgs, + fmt.Sprintf( + "You see %s.", + strings.Join(playersHere, ", "), + ), + ) + } + } + + // Show top-level room items only. + for _, ri := range dest.Items { + if ri.IsPut { + continue + } + + itemDef := e.items[ri.Archetype] + if itemDef == nil { + continue + } + + // Don't expose hidden room machinery/items. + if containsFlag(itemDef.Flags, "HIDDEN") { + continue + } + + itemName := e.formatItemName( + itemDef, + ri.Adj1, + ri.Adj2, + ri.Adj3, + ) + + msgs = append( + msgs, + fmt.Sprintf("You see %s.", itemName), + ) + } + + // Show monsters. + monLines := e.MonsterLookLines(destNum) + msgs = append(msgs, monLines...) + + return &CommandResult{ + Messages: msgs, + } + } + + // Everything below this point is looking at the CURRENT room. + if e.roomVisibility(player, room) == VisibilityDark { + return &CommandResult{ + Messages: []string{ + "It is too dark to see anything.", + }, } } - // "look at me/myself" → examine self + // Look at self. if target == "me" || target == "myself" || target == "self" { return e.examinePlayer(player, player) } - // Check if target is a player (online, in same room) + // Look at another player. if found := e.findPlayerInRoom(player, target); found != nil { return e.examinePlayer(player, found) } - // Check if target is a monster in the room + // Look at a monster. if _, monDef := e.findMonsterInRoom(player, target); monDef != nil { return e.examineMonster(monDef) } - // Check IN/ON/UNDER prefixes + // Check IN / ON / UNDER / BEHIND prefixes. prefix := "" remaining := target - for _, p := range []string{"in ", "on ", "under ", "behind "} { + + for _, p := range []string{ + "in ", + "on ", + "under ", + "behind ", + } { if strings.HasPrefix(target, p) { prefix = strings.ToUpper(strings.TrimSpace(p)) remaining = strings.TrimPrefix(target, p) break } } + remaining, ordSkip := parseOrdinal(remaining) skip := ordSkip - room := e.rooms[player.RoomNumber] - if room == nil { - return &CommandResult{Messages: []string{"You see nothing."}} - } - isContainer := func(def *gameworld.ItemDef) bool { - return def.Type == "CONTAINER" || containsFlag(def.Flags, "CONTAINER") || - def.Container == "IN" || def.Container == "ON" + return def.Type == "CONTAINER" || + containsFlag(def.Flags, "CONTAINER") || + def.Container == "IN" || + def.Container == "ON" } - // Search room items + // Search room items. for _, ri := range room.Items { + if ri.IsPut { + continue + } + itemDef := e.items[ri.Archetype] if itemDef == nil { continue } + name := e.getItemNounName(itemDef) - if matchesTarget(name, remaining, e.getAdjName(ri.Adj1)) { - if skip > 0 { skip--; continue } - if prefix == "IN" && isContainer(itemDef) { - displayName := e.formatItemName(itemDef, ri.Adj1, ri.Adj2, ri.Adj3) - if ri.State == "OPEN" || ri.State == "" { - return &CommandResult{Messages: []string{fmt.Sprintf("You look in %s. It is empty.", displayName)}} + + if !matchesTarget( + name, + remaining, + e.getAdjName(ri.Adj1), + ) { + continue + } + + if skip > 0 { + skip-- + continue + } + + // LOOK IN + if prefix == "IN" && isContainer(itemDef) { + displayName := e.formatItemName( + itemDef, + ri.Adj1, + ri.Adj2, + ri.Adj3, + ) + + if ri.State != "OPEN" && ri.State != "" { + return &CommandResult{ + Messages: []string{ + fmt.Sprintf( + "You'll need to open %s first.", + displayName, + ), + }, + } + } + + msgs := []string{ + fmt.Sprintf("You look in %s.", displayName), + } + + found := false + usedVolume := 0 + + for _, child := range room.Items { + if !child.IsPut || child.PutIn != ri.Ref { + continue + } + + childDef := e.items[child.Archetype] + if childDef == nil { + continue + } + + usedVolume += childDef.Volume + + if childDef.Type == "MONEY" { + switch childDef.Parameter1 { + case 1: + msgs = append(msgs, "You see some gold coins.") + case 2: + msgs = append(msgs, "You see some silver coins.") + case 3: + msgs = append(msgs, "You see some copper coins.") + default: + msgs = append(msgs, "You see some coins.") + } + + found = true + continue } - return &CommandResult{Messages: []string{fmt.Sprintf("You'll need to open %s first.", displayName)}} + + childName := e.formatItemName( + childDef, + child.Adj1, + child.Adj2, + child.Adj3, + ) + + msgs = append( + msgs, + fmt.Sprintf("You see %s.", childName), + ) + + found = true + } + + if itemDef.Interior > 0 { + msgs = append( + msgs, + fmt.Sprintf( + "Capacity: %d/%d", + usedVolume, + itemDef.Interior, + ), + ) + } + + if !found { + msgs = append(msgs, "It is empty.") } - if prefix != "" { - return e.lookPrefixRoomItem(room, itemDef, &ri, prefix) + + return &CommandResult{ + Messages: msgs, } - return e.examineRoomItem(player, room, itemDef, &ri) } + + // Existing ON / UNDER / BEHIND handling. + if prefix != "" { + return e.lookPrefixRoomItem( + room, + itemDef, + &ri, + prefix, + ) + } + + return e.examineRoomItem( + player, + room, + itemDef, + &ri, + ) } - // Search all player items (inventory + worn + wielded) - allItems := make([]InventoryItem, 0, len(player.Inventory)+len(player.Worn)+1) + // Search all player items (inventory + worn + wielded). + allItems := make( + []InventoryItem, + 0, + len(player.Inventory)+len(player.Worn)+1, + ) + allItems = append(allItems, player.Inventory...) allItems = append(allItems, player.Worn...) - if player.Wielded != nil { allItems = append(allItems, *player.Wielded) } + + if player.Wielded != nil { + allItems = append(allItems, *player.Wielded) + } + for _, ii := range allItems { itemDef := e.items[ii.Archetype] if itemDef == nil { continue } + name := e.getItemNounName(itemDef) - if matchesTarget(name, remaining, e.getAdjName(ii.Adj1)) || matchesTarget(name, remaining, e.getAdjName(ii.Adj3)) { - if skip > 0 { skip--; continue } - if prefix == "IN" && isContainer(itemDef) { - return e.lookInContainer(player, itemDef, &ii) - } - if prefix != "" { - displayName := e.formatItemName(itemDef, ii.Adj1, ii.Adj2, ii.Adj3) - return &CommandResult{Messages: []string{fmt.Sprintf("You see nothing noteworthy %s %s.", strings.ToLower(prefix), displayName)}} - } - return &CommandResult{Messages: []string{fmt.Sprintf("You look at your %s.", name)}} - } - } - return &CommandResult{Messages: []string{"You don't see that here."}} -} + if !matchesTarget( + name, + remaining, + e.getAdjName(ii.Adj1), + ) && + !matchesTarget( + name, + remaining, + e.getAdjName(ii.Adj3), + ) { -// findPlayerInRoom finds an online player in the same room by name (first name match). -func (e *GameEngine) findPlayerInRoom(self *Player, target string) *Player { - if e.sessions == nil { - return nil + continue + } + + if skip > 0 { + skip-- + continue + } + + if prefix == "IN" && isContainer(itemDef) { + return e.lookInContainer( + player, + itemDef, + &ii, + ) + } + + if prefix != "" { + displayName := e.formatItemName( + itemDef, + ii.Adj1, + ii.Adj2, + ii.Adj3, + ) + + return &CommandResult{ + Messages: []string{ + fmt.Sprintf( + "You see nothing noteworthy %s %s.", + strings.ToLower(prefix), + displayName, + ), + }, + } + } + + msgs := []string{ + fmt.Sprintf("You look at your %s.", name), + } + + if sm := e.scrollLookMsg(ii.Archetype, ii.Val3); sm != "" { + msgs = append(msgs, sm) + } + + return &CommandResult{ + Messages: msgs, + } + } + + return &CommandResult{ + Messages: []string{ + "You don't see that here.", + }, + } +} + +// scrollLookMsg returns a description line if the item is a scroll, empty string otherwise. +func (e *GameEngine) scrollLookMsg(archetype int, val3 int) string { + if archetype != 168 { + return "" + } + spell := FindSpellByID(val3) + if spell != nil { + return fmt.Sprintf("The scroll contains the spell '%s' (spell #%d).", spell.Name, val3) + } + return fmt.Sprintf("The scroll contains spell #%d.", val3) +} + +// findPlayerInRoom finds an online player in the same room by name (first name match). +func (e *GameEngine) findPlayerInRoom(self *Player, target string) *Player { + if e.sessions == nil { + return nil } for _, p := range e.sessions.OnlinePlayers() { if p.RoomNumber != self.RoomNumber { @@ -2628,87 +3170,363 @@ func (e *GameEngine) examinePlayer(observer *Player, target *Player) *CommandRes } func (e *GameEngine) doGo(ctx context.Context, player *Player, args []string) *CommandResult { + if len(args) == 0 { - return &CommandResult{Messages: []string{"Go where?"}} - } - if player.Position != 0 && player.Position != 4 { - posNames := map[int]string{1: "sitting", 2: "laying down", 3: "kneeling"} - posName := posNames[player.Position] - if posName == "" { posName = "not standing" } - return &CommandResult{Messages: []string{fmt.Sprintf("You can't move while %s! Try STANDing first.", posName)}} + return &CommandResult{ + Messages: []string{"Go where?"}, + } } - target := strings.ToLower(strings.Join(args, " ")) room := e.rooms[player.RoomNumber] if room == nil { - return &CommandResult{Error: "You are nowhere!"} - } - - // Try direction first - dirMap := map[string]string{ - "north": "N", "south": "S", "east": "E", "west": "W", - "northeast": "NE", "northwest": "NW", "southeast": "SE", "southwest": "SW", - "up": "U", "down": "D", "out": "O", - } - if dir, ok := dirMap[target]; ok { - return e.doMove(ctx, player, dir) + return &CommandResult{ + Messages: []string{"You can't go that way."}, + } } + target := strings.ToLower(strings.Join(args, " ")) target, ordSkip := parseOrdinal(target) skip := ordSkip - // Try portals (doors, trails, arches, etc.) + // ------------------------------------------------------------ + // 1. Normal directional exits. + // ------------------------------------------------------------ + + directionAliases := map[string]string{ + "n": "N", + "north": "N", + "s": "S", + "south": "S", + "e": "E", + "east": "E", + "w": "W", + "west": "W", + "ne": "NE", + "northeast": "NE", + "nw": "NW", + "northwest": "NW", + "se": "SE", + "southeast": "SE", + "sw": "SW", + "southwest": "SW", + "u": "U", + "up": "U", + "d": "D", + "down": "D", + "o": "O", + "out": "O", + "above": "ABOVE", + "below": "BELOW", + } + + if dir, ok := directionAliases[target]; ok { + if destNum, exists := room.Exits[dir]; exists { + dest := e.rooms[destNum] + if dest == nil { + return &CommandResult{ + Messages: []string{"You can't go that way."}, + } + } + + oldRoom := player.RoomNumber + + player.RoomNumber = destNum + e.SavePlayer(ctx, player) + + result := e.doLook(player) + + result.OldRoom = oldRoom + result.OldRoomMsg = []string{ + fmt.Sprintf("%s leaves.", player.FirstName), + } + result.RoomBroadcast = append( + result.RoomBroadcast, + fmt.Sprintf("%s arrives.", player.FirstName), + ) + + e.applyEntryScripts(ctx, player, dest, result) + + return result + } + } + + // ------------------------------------------------------------ + // 2. GO + // + // Handles real portals and scripted items such as paths, + // ladders, arches, trees, etc. + // ------------------------------------------------------------ + for i, ri := range room.Items { + if ri.IsPut { + continue + } + itemDef := e.items[ri.Archetype] if itemDef == nil { continue } + name := e.getItemNounName(itemDef) - if !matchesTarget(name, target, e.getAdjName(ri.Adj1)) { + + // Match the noun against any of the item's adjectives. + if !matchesTarget(name, target, e.getAdjName(ri.Adj1)) && + !matchesTarget(name, target, e.getAdjName(ri.Adj2)) && + !matchesTarget(name, target, e.getAdjName(ri.Adj3)) { + continue + } + + if skip > 0 { + skip-- continue } - if skip > 0 { skip--; continue } + + // -------------------------------------------------------- + // Real portal. + // -------------------------------------------------------- + if isPortal(itemDef.Type) { - return e.doGoPortal(ctx, player, room, &room.Items[i], itemDef) + return e.doGoPortal( + ctx, + player, + room, + &room.Items[i], + itemDef, + ) } - // Non-portal item matched — run IFPREVERB GO scripts (e.g., stairways, ladders) - sc := e.RunPreverbScripts(player, room, "GO", &room.Items[i], itemDef) + + // -------------------------------------------------------- + // Scripted non-portal item. + // -------------------------------------------------------- + result := &CommandResult{} - result.Messages = append(result.Messages, sc.Messages...) - result.RoomBroadcast = append(result.RoomBroadcast, sc.RoomMsgs...) - result.GMBroadcast = append(result.GMBroadcast, sc.GMMsgs...) - if sc.Blocked && sc.MoveTo == 0 { - // CLEARVERB without MOVE — block the action - if len(result.Messages) == 0 { - result.Messages = []string{"You can't go that way."} - } + originalRoom := player.RoomNumber + + // -------------------------------------------------------- + // First: IFPREVERB GO + // -------------------------------------------------------- + + pre := e.RunPreverbScripts( + player, + room, + "GO", + &room.Items[i], + itemDef, + ) + + result.Messages = append( + result.Messages, + pre.Messages..., + ) + + result.RoomBroadcast = append( + result.RoomBroadcast, + pre.RoomMsgs..., + ) + + result.GMBroadcast = append( + result.GMBroadcast, + pre.GMMsgs..., + ) + + if e.applyGoScriptResult( + ctx, + player, + originalRoom, + pre, + result, + ) { return result } - if sc.MoveTo > 0 { - dest := e.rooms[sc.MoveTo] - if dest != nil { - oldRoom := player.RoomNumber - player.RoomNumber = sc.MoveTo - e.SavePlayer(ctx, player) - lookResult := e.doLook(player) - result.Messages = append(result.Messages, lookResult.Messages...) - result.RoomName = lookResult.RoomName - result.RoomDesc = lookResult.RoomDesc - result.Exits = lookResult.Exits - result.Items = lookResult.Items - result.OldRoom = oldRoom - result.OldRoomMsg = []string{fmt.Sprintf("%s leaves.", player.FirstName)} - result.RoomBroadcast = append(result.RoomBroadcast, fmt.Sprintf("%s arrives.", player.FirstName)) - e.applyEntryScripts(ctx, player, dest, result) - } + + // -------------------------------------------------------- + // Second: IFVERB GO + // -------------------------------------------------------- + + verb := e.RunVerbScripts( + player, + room, + "GO", + &room.Items[i], + itemDef, + ) + + result.Messages = append( + result.Messages, + verb.Messages..., + ) + + result.RoomBroadcast = append( + result.RoomBroadcast, + verb.RoomMsgs..., + ) + + result.GMBroadcast = append( + result.GMBroadcast, + verb.GMMsgs..., + ) + + if e.applyGoScriptResult( + ctx, + player, + originalRoom, + verb, + result, + ) { + return result } + + // Item matched, but neither PREVERB nor VERB handled GO. if len(result.Messages) == 0 { - result.Messages = []string{"You can't go that way."} + result.Messages = []string{ + "You can't go that way.", + } } + return result } - return &CommandResult{Messages: []string{"You don't see that here."}} + return &CommandResult{ + Messages: []string{"You can't go that way."}, + } +} + +func (e *GameEngine) applyGoScriptResult( + ctx context.Context, + player *Player, + originalRoom int, + sc *ScriptContext, + result *CommandResult, +) bool { + + // MOVEGROUP requested by the script. + // moveGroupToRoom handles movement and sends LOOK to moved players. + if sc.MoveGroupTo > 0 { + e.moveGroupToRoom( + ctx, + originalRoom, + sc.MoveGroupTo, + ) + + dest := e.rooms[player.RoomNumber] + if dest != nil { + lookResult := e.doLook(player) + + result.Messages = append( + result.Messages, + lookResult.Messages..., + ) + + result.RoomName = lookResult.RoomName + result.RoomDesc = lookResult.RoomDesc + result.Exits = lookResult.Exits + result.Items = lookResult.Items + result.OldRoom = originalRoom + } + + return true + + } + + // MOVE requested by the script. + if sc.MoveTo > 0 { + dest := e.rooms[sc.MoveTo] + if dest == nil { + return true + } + + player.RoomNumber = sc.MoveTo + e.SavePlayer(ctx, player) + + lookResult := e.doLook(player) + + result.Messages = append( + result.Messages, + lookResult.Messages..., + ) + + result.RoomName = lookResult.RoomName + result.RoomDesc = lookResult.RoomDesc + result.Exits = lookResult.Exits + result.Items = lookResult.Items + result.OldRoom = originalRoom + + e.applyEntryScripts( + ctx, + player, + dest, + result, + ) + + return true + } + + // Some script actions may perform movement directly. + if player.RoomNumber != originalRoom { + dest := e.rooms[player.RoomNumber] + + e.SavePlayer(ctx, player) + + if dest != nil { + lookResult := e.doLook(player) + + result.Messages = append( + result.Messages, + lookResult.Messages..., + ) + + result.RoomName = lookResult.RoomName + result.RoomDesc = lookResult.RoomDesc + result.Exits = lookResult.Exits + result.Items = lookResult.Items + result.OldRoom = originalRoom + + e.applyEntryScripts( + ctx, + player, + dest, + result, + ) + } + + return true + } + + // CLEARVERB with no movement means the script blocked GO. + if sc.Blocked { + if len(result.Messages) == 0 { + result.Messages = []string{ + "You can't go that way.", + } + } + + return true + } + + return false +} + +func (e *GameEngine) movePlayerToRoom(ctx context.Context, player *Player, roomNum int, result *CommandResult) { + room := e.rooms[roomNum] + if room == nil { + return + } + + oldRoom := player.RoomNumber + + player.RoomNumber = roomNum + e.SavePlayer(ctx, player) + + look := e.doLook(player) + + result.Messages = append(result.Messages, look.Messages...) + result.RoomName = look.RoomName + result.RoomDesc = look.RoomDesc + result.Exits = look.Exits + result.Items = look.Items + result.OldRoom = oldRoom + + e.applyEntryScripts(ctx, player, room, result) } func (e *GameEngine) doGoPortal(ctx context.Context, player *Player, room *gameworld.Room, ri *gameworld.RoomItem, itemDef *gameworld.ItemDef) *CommandResult { @@ -2796,6 +3614,139 @@ func (e *GameEngine) doGoPortal(ctx context.Context, player *Player, room *gamew return result } +func (e *GameEngine) doRerollStats(ctx context.Context, player *Player, args []string) *CommandResult { + + if player.Level >= 2 { + return &CommandResult{ + Messages: []string{ + "You may only reroll your attributes while you are level 1.", + }, + PlayerState: player, + } + } + + argString := strings.ToUpper(strings.Join(args, " ")) + + switch argString { + + case "STATS": + + if player.RoundTimeExpiry.After(time.Now()) { + remaining := int(time.Until(player.RoundTimeExpiry).Seconds()) + 1 + + return &CommandResult{ + Messages: []string{ + fmt.Sprintf("[Wait %d seconds...]", remaining), + }, + PlayerState: player, + } + } + + stats := rollStatsForRace(player.Race) + player.PendingStatReroll = &stats + + player.RoundTimeExpiry = time.Now().Add(5 * time.Second) + + return &CommandResult{ + Messages: []string{ + "Potential new attributes:", + "", + fmt.Sprintf( + "Strength: %d Agility: %d Quickness: %d", + stats.Strength, + stats.Agility, + stats.Quickness, + ), + fmt.Sprintf( + "Constitution: %d Perception: %d Willpower: %d Empathy: %d", + stats.Constitution, + stats.Perception, + stats.Willpower, + stats.Empathy, + ), + "", + "Type REROLL STATS to roll again.", + "Type REROLL STATS CONFIRM to accept these attributes.", + "[Reroll: 5 sec]", + }, + PlayerState: player, + } + + case "STATS CONFIRM": + if player.PendingStatReroll == nil { + return &CommandResult{ + Messages: []string{ + "You don't have a pending stat roll. Type REROLL STATS first.", + }, + PlayerState: player, + } + } + + stats := player.PendingStatReroll + + player.Strength = stats.Strength + player.Agility = stats.Agility + player.Quickness = stats.Quickness + player.Constitution = stats.Constitution + player.Perception = stats.Perception + player.Willpower = stats.Willpower + player.Empathy = stats.Empathy + + // Recalculate derived stats using the same formulas + // as character creation. + bodyPts := 20 + player.Constitution/2 + fatigue := 20 + (player.Constitution+player.Strength)/3 + mana := player.Empathy / 2 + psi := player.Willpower / 2 + + player.BodyPoints = bodyPts + player.MaxBodyPoints = bodyPts + + player.Fatigue = fatigue + player.MaxFatigue = fatigue + + player.Mana = mana + player.MaxMana = mana + + player.Psi = psi + player.MaxPsi = psi + + player.PendingStatReroll = nil + + e.SavePlayer(ctx, player) + + return &CommandResult{ + Messages: []string{ + "Your new attributes have been accepted.", + "", + fmt.Sprintf( + "Strength: %d Agility: %d Quickness: %d", + player.Strength, + player.Agility, + player.Quickness, + ), + fmt.Sprintf( + "Constitution: %d Perception: %d Willpower: %d Empathy: %d", + player.Constitution, + player.Perception, + player.Willpower, + player.Empathy, + ), + }, + PlayerState: player, + } + + default: + return &CommandResult{ + Messages: []string{ + "Usage: REROLL STATS", + " REROLL STATS CONFIRM", + }, + PlayerState: player, + } + } +} + func (e *GameEngine) doClimb(ctx context.Context, player *Player, args []string) *CommandResult { if len(args) == 0 { return &CommandResult{Messages: []string{"Climb what?"}} @@ -2803,7 +3754,9 @@ func (e *GameEngine) doClimb(ctx context.Context, player *Player, args []string) if player.Position != 0 && player.Position != 4 { posNames := map[int]string{1: "sitting", 2: "laying down", 3: "kneeling"} posName := posNames[player.Position] - if posName == "" { posName = "not standing" } + if posName == "" { + posName = "not standing" + } return &CommandResult{Messages: []string{fmt.Sprintf("You can't climb while %s! Try STANDing first.", posName)}} } target := strings.ToLower(strings.Join(args, " ")) @@ -2821,7 +3774,10 @@ func (e *GameEngine) doClimb(ctx context.Context, player *Player, args []string) } name := e.getItemNounName(itemDef) if matchesTarget(name, target, e.getAdjName(ri.Adj1)) { - if skip > 0 { skip--; continue } + if skip > 0 { + skip-- + continue + } if isPortal(itemDef.Type) { return e.doGoPortal(ctx, player, room, &room.Items[i], itemDef) } @@ -2881,7 +3837,10 @@ func (e *GameEngine) doItemInteraction(ctx context.Context, player *Player, verb } name := e.getItemNounName(itemDef) if matchesTarget(name, target, e.getAdjName(ri.Adj1)) { - if skip > 0 { skip--; continue } + if skip > 0 { + skip-- + continue + } result := &CommandResult{} // Run IFPREVERB scripts (room-level and item-level) sc := e.RunPreverbScripts(player, room, verb, &room.Items[i], itemDef) @@ -2943,7 +3902,10 @@ func (e *GameEngine) doItemInteraction(ctx context.Context, player *Player, verb } name := e.getItemNounName(itemDef) if matchesTarget(name, target, e.getAdjName(ii.Adj1)) || matchesTarget(name, target, e.getAdjName(ii.Adj3)) { - if skip > 0 { skip--; continue } + if skip > 0 { + skip-- + continue + } // Create a temporary RoomItem for script context tempRI := gameworld.RoomItem{Ref: -1, Archetype: ii.Archetype, Adj1: ii.Adj1, Adj2: ii.Adj2, Adj3: ii.Adj3, @@ -2968,34 +3930,97 @@ func (e *GameEngine) doItemInteraction(ctx context.Context, player *Player, verb return &CommandResult{Messages: []string{"You don't see that here."}} } +func (e *GameEngine) doATest(ctx context.Context, player *Player) *CommandResult { + room := e.rooms[player.RoomNumber] + + return &CommandResult{ + Messages: []string{ + fmt.Sprintf( + "TEST: room=%d name=%s modifiers=%v bank=%t", + player.RoomNumber, + room.Name, + room.Modifiers, + containsModifier(room.Modifiers, "BANK"), + ), + }, + PlayerState: player, + } +} + func (e *GameEngine) doGet(ctx context.Context, player *Player, args []string) *CommandResult { + if len(args) == 0 { - return &CommandResult{Messages: []string{"Get what?"}} + return &CommandResult{ + Messages: []string{"Get what?"}, + } + } + + raw := strings.ToLower(strings.Join(args, " ")) + + if strings.Contains(raw, " from ") { + return e.doGetFromContainer(ctx, player, raw) } + target := strings.ToLower(strings.Join(args, " ")) target, ordSkip := parseOrdinal(target) skip := ordSkip + room := e.rooms[player.RoomNumber] if room == nil { - return &CommandResult{Messages: []string{"You can't do that here."}} + return &CommandResult{ + Messages: []string{"You can't do that here."}, + } } for i, ri := range room.Items { - // Handle coin piles (State == "MONEY", may have Archetype 0) - if ri.State == "MONEY" && (target == "coins" || target == "money" || target == "coin" || target == "gold" || target == "silver" || target == "copper") { + if ri.IsPut { + continue + } + // Handle coin piles that may not have a valid archetype. + if ri.State == "MONEY" && + (target == "coins" || + target == "money" || + target == "coin" || + target == "gold" || + target == "silver" || + target == "copper") { + coins := ri.Val1 - if coins <= 0 { coins = 1 } - room.Items = append(room.Items[:i], room.Items[i+1:]...) - e.notifyRoomChange(RoomChange{RoomNumber: player.RoomNumber, Type: "item_remove", ItemRef: ri.Ref}) + if coins <= 0 { + coins = 1 + } + + room.Items = append( + room.Items[:i], + room.Items[i+1:]..., + ) + + e.notifyRoomChange(RoomChange{ + RoomNumber: player.RoomNumber, + Type: "item_remove", + ItemRef: ri.Ref, + }) + player.Copper += coins + player.Silver += player.Copper / 10 - player.Copper = player.Copper % 10 + player.Copper %= 10 + player.Gold += player.Silver / 10 - player.Silver = player.Silver % 10 + player.Silver %= 10 + e.SavePlayer(ctx, player) + return &CommandResult{ - Messages: []string{fmt.Sprintf("You pick up %d coins.", coins)}, - RoomBroadcast: []string{fmt.Sprintf("%s picks up some coins.", player.FirstName)}, + Messages: []string{ + fmt.Sprintf("You pick up %d coins.", coins), + }, + RoomBroadcast: []string{ + fmt.Sprintf( + "%s picks up some coins.", + player.FirstName, + ), + }, } } @@ -3003,66 +4028,1758 @@ func (e *GameEngine) doGet(ctx context.Context, player *Player, args []string) * if itemDef == nil { continue } + if itemDef.Weight >= 1000 { - continue // immovable + continue } + if isPortal(itemDef.Type) { continue } - if containsFlag(itemDef.Flags, "FIXED") || itemDef.Type == "MANUSCRIPT" { - continue // can't pick up fixed items or manuscripts + + if containsFlag(itemDef.Flags, "FIXED") || + itemDef.Type == "MANUSCRIPT" { + continue + } + + name := e.getItemNounName(itemDef) + + if !matchesTarget( + name, + target, + e.getAdjName(ri.Adj1), + ) { + continue + } + + if skip > 0 { + skip-- + continue + } + + // MONEY item definitions automatically convert to currency. + if itemDef.Type == "MONEY" || ri.State == "MONEY" { + coins := ri.Val1 + if coins <= 0 { + coins = 1 + } + + room.Items = append( + room.Items[:i], + room.Items[i+1:]..., + ) + + e.notifyRoomChange(RoomChange{ + RoomNumber: player.RoomNumber, + Type: "item_remove", + ItemRef: ri.Ref, + }) + + player.Copper += coins + + player.Silver += player.Copper / 10 + player.Copper %= 10 + + player.Gold += player.Silver / 10 + player.Silver %= 10 + + e.SavePlayer(ctx, player) + + return &CommandResult{ + Messages: []string{ + fmt.Sprintf("You pick up %d coins.", coins), + }, + RoomBroadcast: []string{ + fmt.Sprintf( + "%s picks up some coins.", + player.FirstName, + ), + }, + } + } + + /* + Copy the selected item before running its script. + + The script may execute NEWPUT, which can append another item + to room.Items. Using a copy prevents ScriptContext.ItemRef from + becoming an invalid pointer if the room item slice reallocates. + */ + pickedUp := ri + + // Run item and room IFPREVERB GET scripts. + sc := e.RunPreverbScripts( + player, + room, + "GET", + &pickedUp, + itemDef, + ) + + result := &CommandResult{} + + result.Messages = append( + result.Messages, + sc.Messages..., + ) + + result.RoomBroadcast = append( + result.RoomBroadcast, + sc.RoomMsgs..., + ) + + result.GMBroadcast = append( + result.GMBroadcast, + sc.GMMsgs..., + ) + + // CLEARVERB cancels the normal GET action. + if sc.Blocked { + if len(result.Messages) == 0 { + result.Messages = append( + result.Messages, + "You can't get that.", + ) + } + + // Save any player-variable changes made by the script. + e.SavePlayer(ctx, player) + + return result + } + + ///get containers + var contents []InventoryItem + + for _, child := range room.Items { + if !child.IsPut || child.PutIn != pickedUp.Ref { + continue + } + + contents = append(contents, InventoryItem{ + Archetype: child.Archetype, + Adj1: child.Adj1, + Adj2: child.Adj2, + Adj3: child.Adj3, + Val1: child.Val1, + Val2: child.Val2, + Val3: child.Val3, + Val4: child.Val4, + Val5: child.Val5, + State: child.State, + }) + } + + // Move the selected room item into the player's inventory. + player.Inventory = append( + player.Inventory, + InventoryItem{ + Archetype: pickedUp.Archetype, + Adj1: pickedUp.Adj1, + Adj2: pickedUp.Adj2, + Adj3: pickedUp.Adj3, + Val1: pickedUp.Val1, + Val2: pickedUp.Val2, + Val3: pickedUp.Val3, + Val4: pickedUp.Val4, + Val5: pickedUp.Val5, + State: pickedUp.State, + Contents: contents, //add any contents if this is a container + }, + ) + + //remove any items that were inside the container from the room + if len(contents) > 0 { + filtered := room.Items[:0] + + for _, item := range room.Items { + if item.IsPut && item.PutIn == pickedUp.Ref { + continue + } + + filtered = append(filtered, item) + } + + room.Items = filtered + } + + /* + NEWPUT may have appended a replacement item while the script + was running. Find the original item again instead of assuming + the room slice is completely unchanged. + */ + removeIndex := -1 + + for j := range room.Items { + candidate := room.Items[j] + + if candidate.Ref == pickedUp.Ref && + candidate.Archetype == pickedUp.Archetype && + candidate.Adj1 == pickedUp.Adj1 && + candidate.Adj2 == pickedUp.Adj2 && + candidate.Adj3 == pickedUp.Adj3 { + + removeIndex = j + break + } + } + + if removeIndex >= 0 { + room.Items = append( + room.Items[:removeIndex], + room.Items[removeIndex+1:]..., + ) + + e.notifyRoomChange(RoomChange{ + RoomNumber: player.RoomNumber, + Type: "item_remove", + ItemRef: pickedUp.Ref, + }) + } + + e.SavePlayer(ctx, player) + + fullName := e.formatItemName( + itemDef, + pickedUp.Adj1, + pickedUp.Adj2, + pickedUp.Adj3, + ) + + result.Messages = append( + result.Messages, + fmt.Sprintf( + "You pick up %s.", + fullName, + ), + ) + + result.RoomBroadcast = append( + result.RoomBroadcast, + fmt.Sprintf( + "%s picks up %s.", + player.FirstName, + fullName, + ), + ) + + return result + } + + return &CommandResult{ + Messages: []string{"You don't see that here."}, + } +} + +func (e *GameEngine) doGetFromContainer(ctx context.Context, player *Player, raw string) *CommandResult { + + parts := strings.SplitN(raw, " from ", 2) + if len(parts) != 2 { + return &CommandResult{ + Messages: []string{"Get what from what?"}, + } + } + + itemTarget := strings.TrimSpace(parts[0]) + containerTarget := strings.TrimSpace(parts[1]) + + if itemTarget == "" || containerTarget == "" { + return &CommandResult{ + Messages: []string{"Get what from what?"}, + } + } + + // Support: + // GET COIN FROM CHEST 2 + // GET COIN FROM SECOND CHEST + containerTarget, containerOrdSkip := parseOrdinal(containerTarget) + + room := e.rooms[player.RoomNumber] + if room == nil { + return &CommandResult{ + Messages: []string{"You can't do that here."}, + } + } + + // ------------------------------------------------------------ + // Find a matching room container. + // ------------------------------------------------------------ + + var container *gameworld.RoomItem + var containerDef *gameworld.ItemDef + + containerSkip := containerOrdSkip + + for i := range room.Items { + ri := &room.Items[i] + + // Containers themselves must be top-level room items. + if ri.IsPut { + continue + } + + def := e.items[ri.Archetype] + if def == nil { + continue + } + + name := e.getItemNounName(def) + + if !matchesTarget( + name, + containerTarget, + e.getAdjName(ri.Adj1), + ) && + !matchesTarget( + name, + containerTarget, + e.getAdjName(ri.Adj3), + ) { + continue + } + + // Only count actual containers toward the ordinal. + if def.Container != "IN" && + def.Type != "CONTAINER" && + !containsFlag(def.Flags, "CONTAINER") { + continue + } + + if containerSkip > 0 { + containerSkip-- + continue + } + + container = ri + containerDef = def + break + } + + // ------------------------------------------------------------ + // If no room container matched, try carried containers. + // ------------------------------------------------------------ + + if container == nil { + containerSkip = containerOrdSkip + + for ci := range player.Inventory { + invContainer := &player.Inventory[ci] + + def := e.items[invContainer.Archetype] + if def == nil { + continue + } + + name := e.getItemNounName(def) + + if !matchesTarget( + name, + containerTarget, + e.getAdjName(invContainer.Adj1), + ) && + !matchesTarget( + name, + containerTarget, + e.getAdjName(invContainer.Adj3), + ) { + continue + } + + if def.Container != "IN" && + def.Type != "CONTAINER" && + !containsFlag(def.Flags, "CONTAINER") { + continue + } + + if containerSkip > 0 { + containerSkip-- + continue + } + + containerName := e.formatItemName( + def, + invContainer.Adj1, + invContainer.Adj2, + invContainer.Adj3, + ) + + if invContainer.State != "OPEN" && invContainer.State != "" { + return &CommandResult{ + Messages: []string{ + fmt.Sprintf( + "You'll need to open %s first.", + containerName, + ), + }, + } + } + + // Support: + // GET SECOND SCROLL FROM SACK + parsedItemTarget, ordSkip := parseOrdinal(itemTarget) + skip := ordSkip + + for ii := range invContainer.Contents { + child := invContainer.Contents[ii] + + childDef := e.items[child.Archetype] + if childDef == nil { + continue + } + + childName := e.getItemNounName(childDef) + + if !matchesTarget( + childName, + parsedItemTarget, + e.getAdjName(child.Adj1), + ) && + !matchesTarget( + childName, + parsedItemTarget, + e.getAdjName(child.Adj3), + ) { + continue + } + + if skip > 0 { + skip-- + continue + } + + // MONEY inside a carried container. + if childDef.Type == "MONEY" || child.State == "MONEY" { + coins := child.Val1 + if coins <= 0 { + coins = 1 + } + + invContainer.Contents = append( + invContainer.Contents[:ii], + invContainer.Contents[ii+1:]..., + ) + + player.Copper += coins + + player.Silver += player.Copper / 10 + player.Copper %= 10 + + player.Gold += player.Silver / 10 + player.Silver %= 10 + + e.SavePlayer(ctx, player) + + return &CommandResult{ + Messages: []string{ + fmt.Sprintf( + "You take %d coins from %s.", + coins, + containerName, + ), + }, + RoomBroadcast: []string{ + fmt.Sprintf( + "%s takes some coins from %s.", + player.FirstName, + containerName, + ), + }, + } + } + + // Remove from container. + invContainer.Contents = append( + invContainer.Contents[:ii], + invContainer.Contents[ii+1:]..., + ) + + // Add to ordinary inventory. + player.Inventory = append( + player.Inventory, + child, + ) + + e.SavePlayer(ctx, player) + + fullName := e.formatItemName( + childDef, + child.Adj1, + child.Adj2, + child.Adj3, + ) + + return &CommandResult{ + Messages: []string{ + fmt.Sprintf( + "You take %s from %s.", + fullName, + containerName, + ), + }, + RoomBroadcast: []string{ + fmt.Sprintf( + "%s takes %s from %s.", + player.FirstName, + fullName, + containerName, + ), + }, + } + } + + return &CommandResult{ + Messages: []string{ + fmt.Sprintf( + "You don't see that in %s.", + containerName, + ), + }, + } + } + + return &CommandResult{ + Messages: []string{"You don't see that container here."}, + } + } + + // ------------------------------------------------------------ + // Room container found. + // ------------------------------------------------------------ + + containerName := e.formatItemName( + containerDef, + container.Adj1, + container.Adj2, + container.Adj3, + ) + + if container.State != "OPEN" && container.State != "" { + return &CommandResult{ + Messages: []string{ + fmt.Sprintf( + "You'll need to open %s first.", + containerName, + ), + }, + } + } + + // Support: + // GET SECOND SCROLL FROM BIN + parsedItemTarget, ordSkip := parseOrdinal(itemTarget) + skip := ordSkip + + // Find only items PUT inside this specific container. + for i, ri := range room.Items { + if !ri.IsPut || ri.PutIn != container.Ref { + continue + } + + itemDef := e.items[ri.Archetype] + if itemDef == nil { + continue + } + + name := e.getItemNounName(itemDef) + + if !matchesTarget( + name, + parsedItemTarget, + e.getAdjName(ri.Adj1), + ) && + !matchesTarget( + name, + parsedItemTarget, + e.getAdjName(ri.Adj3), + ) { + continue + } + + if skip > 0 { + skip-- + continue + } + + // Handle money inside containers. + if itemDef.Type == "MONEY" || ri.State == "MONEY" { + coins := ri.Val1 + if coins <= 0 { + coins = 1 + } + + room.Items = append( + room.Items[:i], + room.Items[i+1:]..., + ) + + player.Copper += coins + player.Silver += player.Copper / 10 + player.Copper %= 10 + player.Gold += player.Silver / 10 + player.Silver %= 10 + + e.SavePlayer(ctx, player) + + return &CommandResult{ + Messages: []string{ + fmt.Sprintf( + "You take %d coins from %s.", + coins, + containerName, + ), + }, + RoomBroadcast: []string{ + fmt.Sprintf( + "%s takes some coins from %s.", + player.FirstName, + containerName, + ), + }, + } + } + + pickedUp := ri + + // Run normal GET preverb scripts on the item. + sc := e.RunPreverbScripts( + player, + room, + "GET", + &pickedUp, + itemDef, + ) + + result := &CommandResult{ + Messages: append([]string{}, sc.Messages...), + RoomBroadcast: append([]string{}, sc.RoomMsgs...), + GMBroadcast: append([]string{}, sc.GMMsgs...), + } + + if sc.Blocked { + if len(result.Messages) == 0 { + result.Messages = []string{"You can't get that."} + } + + e.SavePlayer(ctx, player) + return result + } + + player.Inventory = append( + player.Inventory, + InventoryItem{ + Archetype: pickedUp.Archetype, + Adj1: pickedUp.Adj1, + Adj2: pickedUp.Adj2, + Adj3: pickedUp.Adj3, + Val1: pickedUp.Val1, + Val2: pickedUp.Val2, + Val3: pickedUp.Val3, + Val4: pickedUp.Val4, + Val5: pickedUp.Val5, + State: pickedUp.State, + }, + ) + + /* + The GET script may modify room.Items, so locate the exact + PUT item again before removing it. + */ + removeIndex := -1 + + for j := range room.Items { + candidate := room.Items[j] + + if candidate.IsPut && + candidate.PutIn == pickedUp.PutIn && + candidate.Ref == pickedUp.Ref && + candidate.Archetype == pickedUp.Archetype && + candidate.Adj1 == pickedUp.Adj1 && + candidate.Adj2 == pickedUp.Adj2 && + candidate.Adj3 == pickedUp.Adj3 { + + removeIndex = j + break + } + } + + if removeIndex >= 0 { + room.Items = append( + room.Items[:removeIndex], + room.Items[removeIndex+1:]..., + ) + } + + e.SavePlayer(ctx, player) + + fullName := e.formatItemName( + itemDef, + pickedUp.Adj1, + pickedUp.Adj2, + pickedUp.Adj3, + ) + + result.Messages = append( + result.Messages, + fmt.Sprintf( + "You take %s from %s.", + fullName, + containerName, + ), + ) + + result.RoomBroadcast = append( + result.RoomBroadcast, + fmt.Sprintf( + "%s takes %s from %s.", + player.FirstName, + fullName, + containerName, + ), + ) + + return result + } + + return &CommandResult{ + Messages: []string{ + fmt.Sprintf( + "You don't see that in %s.", + containerName, + ), + }, + } +} + +func (e *GameEngine) doPut( + ctx context.Context, + player *Player, + args []string, +) *CommandResult { + + if len(args) < 3 { + return &CommandResult{ + Messages: []string{"Put what in what?"}, + } + } + + raw := strings.ToLower(strings.Join(args, " ")) + + // Support: PUT IN + inIdx := strings.Index(raw, " in ") + if inIdx < 0 { + return &CommandResult{ + Messages: []string{"Put what in what?"}, + } + } + + itemTarget := strings.TrimSpace(raw[:inIdx]) + containerTarget := strings.TrimSpace(raw[inIdx+4:]) + + if itemTarget == "" || containerTarget == "" { + return &CommandResult{ + Messages: []string{"Put what in what?"}, + } + } + + room := e.rooms[player.RoomNumber] + if room == nil { + return &CommandResult{ + Messages: []string{"You can't do that here."}, + } + } + + // ------------------------------------------------------------ + // Find the destination object in the room. + // Do NOT require it to be a real container yet because + // IFPREVERB2 PUT may handle things like knotholes/windows/etc. + // ------------------------------------------------------------ + + containerIndex := -1 + + for i, ri := range room.Items { + if ri.IsPut { + continue + } + + def := e.items[ri.Archetype] + if def == nil { + continue + } + + name := e.getItemNounName(def) + + if matchesTarget( + name, + containerTarget, + e.getAdjName(ri.Adj1), + ) { + containerIndex = i + break + } + } + + // If no room target matched, try a container carried by the player. + if containerIndex < 0 { + return e.doPutInInventoryContainer( + ctx, + player, + itemTarget, + containerTarget, + ) + } + + container := &room.Items[containerIndex] + containerDef := e.items[container.Archetype] + + if containerDef == nil { + return &CommandResult{ + Messages: []string{"You can't put anything in that."}, + } + } + + // ------------------------------------------------------------ + // Find object #1 in the player's inventory FIRST. + // This prevents nonsense like: + // PUT CAT IN KNOT + // when the player doesn't have a cat. + // ------------------------------------------------------------ + + itemIndex := -1 + + for i, ii := range player.Inventory { + def := e.items[ii.Archetype] + if def == nil { + continue + } + + name := e.getItemNounName(def) + + if matchesTarget( + name, + itemTarget, + e.getAdjName(ii.Adj1), + ) || + matchesTarget( + name, + itemTarget, + e.getAdjName(ii.Adj3), + ) { + + itemIndex = i + break + } + } + + if itemIndex < 0 { + return &CommandResult{ + Messages: []string{"You aren't carrying that."}, + } + } + + ii := player.Inventory[itemIndex] + itemDef := e.items[ii.Archetype] + + if itemDef == nil { + return &CommandResult{ + Messages: []string{"You aren't carrying that."}, + } + } + + // ------------------------------------------------------------ + // Now run PUT preverb scripts against object #2. + // + // RunPreverbScripts currently checks both: + // IFPREVERB + // IFPREVERB2 + // + // This is where IFPREVERB2 PUT can intercept the action. + // ------------------------------------------------------------ + + scriptItem := gameworld.RoomItem{ + Ref: -1, + Archetype: ii.Archetype, + Adj1: ii.Adj1, + Adj2: ii.Adj2, + Adj3: ii.Adj3, + Val1: ii.Val1, + Val2: ii.Val2, + Val3: ii.Val3, + Val4: ii.Val4, + Val5: ii.Val5, + State: ii.State, + } + + sc := e.RunPreverbScripts( + player, + room, + "PUT", + &scriptItem, // object #1: the dust/pelt/etc. + itemDef, + container, // object #2: the knothole + ) + + result := &CommandResult{ + Messages: append([]string{}, sc.Messages...), + RoomBroadcast: append([]string{}, sc.RoomMsgs...), + GMBroadcast: append([]string{}, sc.GMMsgs...), + } + + // CLEARVERB means the script handled or cancelled the PUT. + if sc.Blocked { + e.SavePlayer(ctx, player) + + if len(result.Messages) == 0 { + result.Messages = []string{"You can't do that."} + } + + return result + } + + // ------------------------------------------------------------ + // No script intercepted it, so from here on it must be a + // normal container. + // ------------------------------------------------------------ + + if containerDef.Container != "IN" && + containerDef.Type != "CONTAINER" && + !containsFlag(containerDef.Flags, "CONTAINER") { + + return &CommandResult{ + Messages: []string{"You can't put anything in that."}, + } + } + + // Container must be open. + if container.State != "OPEN" && container.State != "" { + displayName := e.formatItemName( + containerDef, + container.Adj1, + container.Adj2, + container.Adj3, + ) + + return &CommandResult{ + Messages: []string{ + fmt.Sprintf( + "You'll need to open %s first.", + displayName, + ), + }, + } + } + + // ------------------------------------------------------------ + // Normal container volume rules. + // ------------------------------------------------------------ + + if itemDef.Volume >= containerDef.Volume { + return &CommandResult{ + Messages: []string{ + "That item is too large to fit in the container.", + }, + } + } + + usedVolume := 0 + + for _, ri := range room.Items { + if !ri.IsPut || ri.PutIn != container.Ref { + continue + } + + if def := e.items[ri.Archetype]; def != nil { + usedVolume += def.Volume + } + } + + if usedVolume+itemDef.Volume > containerDef.Interior { + return &CommandResult{ + Messages: []string{ + "There isn't enough room in the container.", + }, + } + } + + // ------------------------------------------------------------ + // Perform normal PUT. + // ------------------------------------------------------------ + + putItem := gameworld.RoomItem{ + Ref: container.Ref, + Archetype: ii.Archetype, + Adj1: ii.Adj1, + Adj2: ii.Adj2, + Adj3: ii.Adj3, + Val1: ii.Val1, + Val2: ii.Val2, + Val3: ii.Val3, + Val4: ii.Val4, + Val5: ii.Val5, + State: ii.State, + IsPut: true, + PutIn: container.Ref, + } + + room.Items = append(room.Items, putItem) + + // Remove from player inventory. + player.Inventory = append( + player.Inventory[:itemIndex], + player.Inventory[itemIndex+1:]..., + ) + + e.SavePlayer(ctx, player) + + itemName := e.formatItemName( + itemDef, + ii.Adj1, + ii.Adj2, + ii.Adj3, + ) + + containerName := e.formatItemName( + containerDef, + container.Adj1, + container.Adj2, + container.Adj3, + ) + + result.Messages = append( + result.Messages, + fmt.Sprintf( + "You put %s in %s.", + itemName, + containerName, + ), + ) + + result.RoomBroadcast = append( + result.RoomBroadcast, + fmt.Sprintf( + "%s puts %s in %s.", + player.FirstName, + itemName, + containerName, + ), + ) + + return result +} + +func (e *GameEngine) doPutInInventoryContainer(ctx context.Context, player *Player, itemTarget string, containerTarget string) *CommandResult { + + containerTarget, ordSkip := parseOrdinal(containerTarget) + skip := ordSkip + + for containerIndex := range player.Inventory { + container := &player.Inventory[containerIndex] + + containerDef := e.items[container.Archetype] + if containerDef == nil { + continue + } + + containerNoun := e.getItemNounName(containerDef) + + if !matchesTarget( + containerNoun, + containerTarget, + e.getAdjName(container.Adj1), + ) && + !matchesTarget( + containerNoun, + containerTarget, + e.getAdjName(container.Adj3), + ) { + continue + } + + if containerDef.Container != "IN" && + containerDef.Type != "CONTAINER" && + !containsFlag(containerDef.Flags, "CONTAINER") { + continue + } + + // Ordinal support: + // PUT CLAW IN CHEST 2 + // PUT CLAW IN SECOND CHEST + if skip > 0 { + skip-- + continue + } + + containerName := e.formatItemName( + containerDef, + container.Adj1, + container.Adj2, + container.Adj3, + ) + + if container.State != "OPEN" && container.State != "" { + return &CommandResult{ + Messages: []string{ + fmt.Sprintf( + "You'll need to open %s first.", + containerName, + ), + }, + } + } + + // Find the item being placed into the container. + itemTargetParsed, itemOrdSkip := parseOrdinal(itemTarget) + itemSkip := itemOrdSkip + itemIndex := -1 + + for i := range player.Inventory { + // Don't allow putting a container into itself. + if i == containerIndex { + continue + } + + item := &player.Inventory[i] + + itemDef := e.items[item.Archetype] + if itemDef == nil { + continue + } + + itemNoun := e.getItemNounName(itemDef) + + if !matchesTarget( + itemNoun, + itemTargetParsed, + e.getAdjName(item.Adj1), + ) && + !matchesTarget( + itemNoun, + itemTargetParsed, + e.getAdjName(item.Adj3), + ) { + continue + } + + if itemSkip > 0 { + itemSkip-- + continue + } + + itemIndex = i + break + } + + if itemIndex < 0 { + return &CommandResult{ + Messages: []string{ + "You aren't carrying that.", + }, + } + } + + item := player.Inventory[itemIndex] + itemDef := e.items[item.Archetype] + + // --------------------------------------------------------- + // Container volume rules from original game docs. + // --------------------------------------------------------- + + // Item itself must be smaller than the container's VOLUME. + if itemDef.Volume >= containerDef.Volume { + return &CommandResult{ + Messages: []string{ + "That item is too large to fit in the container.", + }, + } + } + + // Total contents may not exceed container INTERIOR. + usedVolume := 0 + + for _, child := range container.Contents { + if def := e.items[child.Archetype]; def != nil { + usedVolume += def.Volume + } + } + + if usedVolume+itemDef.Volume > containerDef.Interior { + return &CommandResult{ + Messages: []string{ + "There isn't enough room in the container.", + }, + } + } + + /* + Remove the item before modifying the container. + + Removing from player.Inventory can shift the container's + index, so adjust it if necessary. + */ + player.Inventory = append( + player.Inventory[:itemIndex], + player.Inventory[itemIndex+1:]..., + ) + + if itemIndex < containerIndex { + containerIndex-- + } + + player.Inventory[containerIndex].Contents = append( + player.Inventory[containerIndex].Contents, + item, + ) + + e.SavePlayer(ctx, player) + + itemName := e.formatItemName( + itemDef, + item.Adj1, + item.Adj2, + item.Adj3, + ) + + return &CommandResult{ + Messages: []string{ + fmt.Sprintf( + "You put %s in %s.", + itemName, + containerName, + ), + }, + RoomBroadcast: []string{ + fmt.Sprintf( + "%s puts %s in %s.", + player.FirstName, + itemName, + containerName, + ), + }, + } + } + + return &CommandResult{ + Messages: []string{ + "You don't see that container here.", + }, + } +} + +func (e *GameEngine) doDrop(ctx context.Context, player *Player, args []string) *CommandResult { + if len(args) == 0 { + return &CommandResult{ + Messages: []string{"Drop what?"}, + } + } + + target := strings.ToLower(strings.Join(args, " ")) + target, ordSkip := parseOrdinal(target) + skip := ordSkip + + room := e.rooms[player.RoomNumber] + if room == nil { + return &CommandResult{ + Messages: []string{"You can't do that here."}, + } + } + + for i, ii := range player.Inventory { + itemDef := e.items[ii.Archetype] + if itemDef == nil { + continue + } + + name := e.getItemNounName(itemDef) + if !matchesTarget(name, target, e.getAdjName(ii.Adj1)) { + continue + } + + if skip > 0 { + skip-- + continue + } + + /* + Run the inventory item's IFPREVERB DROP script before + performing the normal drop. + + RunPreverbScripts currently expects a RoomItem, so create + a temporary script representation of the inventory item. + */ + scriptItem := gameworld.RoomItem{ + Ref: -1, + Archetype: ii.Archetype, + Adj1: ii.Adj1, + Adj2: ii.Adj2, + Adj3: ii.Adj3, + Val1: ii.Val1, + Val2: ii.Val2, + Val3: ii.Val3, + Val4: ii.Val4, + Val5: ii.Val5, + State: ii.State, + } + + sc := e.RunPreverbScripts( + player, + room, + "DROP", + &scriptItem, + itemDef, + ) + + result := &CommandResult{ + Messages: append([]string{}, sc.Messages...), + RoomBroadcast: append([]string{}, sc.RoomMsgs...), + GMBroadcast: append([]string{}, sc.GMMsgs...), + } + + /* + CLEARVERB means the script handled or blocked the drop. + + For the claws, REMOVEITEM -1 has already deleted them + from inventory, so we must not perform the normal drop. + */ + if sc.Blocked { + if len(result.Messages) == 0 { + result.Messages = []string{"You can't drop that."} + } + + // Persist inventory and variable changes made by the script. + e.SavePlayer(ctx, player) + + return result + } + + /* + The script did not block the command, so perform the + ordinary inventory-to-room transfer. + */ + droppedItem := gameworld.RoomItem{ + Ref: len(room.Items), + Archetype: ii.Archetype, + Adj1: ii.Adj1, + Adj2: ii.Adj2, + Adj3: ii.Adj3, + Val1: ii.Val1, + Val2: ii.Val2, + Val3: ii.Val3, + Val4: ii.Val4, + Val5: ii.Val5, + State: ii.State, + } + + room.Items = append(room.Items, droppedItem) + + e.notifyRoomChange(RoomChange{ + RoomNumber: player.RoomNumber, + Type: "item_add", + Item: &droppedItem, + }) + + // Restore any carried container contents as PUT items in the room. + for _, child := range ii.Contents { + putItem := gameworld.RoomItem{ + Ref: droppedItem.Ref, + Archetype: child.Archetype, + Adj1: child.Adj1, + Adj2: child.Adj2, + Adj3: child.Adj3, + Val1: child.Val1, + Val2: child.Val2, + Val3: child.Val3, + Val4: child.Val4, + Val5: child.Val5, + State: child.State, + IsPut: true, + PutIn: droppedItem.Ref, + } + + room.Items = append(room.Items, putItem) + + e.notifyRoomChange(RoomChange{ + RoomNumber: player.RoomNumber, + Type: "item_add", + Item: &putItem, + }) + } + + player.Inventory = append( + player.Inventory[:i], + player.Inventory[i+1:]..., + ) + + e.SavePlayer(ctx, player) + + fullName := e.formatItemName( + itemDef, + ii.Adj1, + ii.Adj2, + ii.Adj3, + ) + + result.Messages = append( + result.Messages, + fmt.Sprintf("You drop %s.", fullName), + ) + + result.RoomBroadcast = append( + result.RoomBroadcast, + fmt.Sprintf("%s drops %s.", player.FirstName, fullName), + ) + + return result + } + + return &CommandResult{ + Messages: []string{"You aren't carrying that."}, + } +} + +func (e *GameEngine) doInventory(player *Player) *CommandResult { + var msgs []string + + msgs = append(msgs, "You are carrying:") + + if len(player.Inventory) == 0 && + len(player.Worn) == 0 && + player.Wielded == nil && + player.Offhand == nil { + + msgs = append(msgs, " Nothing.") + return &CommandResult{Messages: msgs} + } + + // Main hand. + if player.Wielded != nil { + itemDef := e.items[player.Wielded.Archetype] + if itemDef != nil { + name := e.formatItemName( + itemDef, + player.Wielded.Adj1, + player.Wielded.Adj2, + player.Wielded.Adj3, + ) + + msgs = append( + msgs, + fmt.Sprintf(" %s (wielded)", name), + ) } - name := e.getItemNounName(itemDef) - if matchesTarget(name, target, e.getAdjName(ri.Adj1)) { - if skip > 0 { skip--; continue } + } - // MONEY items auto-convert to currency - if itemDef.Type == "MONEY" || ri.State == "MONEY" { - coins := ri.Val1 - if coins <= 0 { coins = 1 } - room.Items = append(room.Items[:i], room.Items[i+1:]...) - e.notifyRoomChange(RoomChange{RoomNumber: player.RoomNumber, Type: "item_remove", ItemRef: ri.Ref}) - player.Copper += coins - // Auto-convert up - player.Silver += player.Copper / 10 - player.Copper = player.Copper % 10 - player.Gold += player.Silver / 10 - player.Silver = player.Silver % 10 - e.SavePlayer(ctx, player) - return &CommandResult{ - Messages: []string{fmt.Sprintf("You pick up %d coins.", coins)}, - RoomBroadcast: []string{fmt.Sprintf("%s picks up some coins.", player.FirstName)}, - } - } + // Off hand. + if player.Offhand != nil { + itemDef := e.items[player.Offhand.Archetype] + if itemDef != nil { + name := e.formatItemName( + itemDef, + player.Offhand.Adj1, + player.Offhand.Adj2, + player.Offhand.Adj3, + ) - // Add to inventory - player.Inventory = append(player.Inventory, InventoryItem{ - Archetype: ri.Archetype, - Adj1: ri.Adj1, Adj2: ri.Adj2, Adj3: ri.Adj3, - Val1: ri.Val1, Val2: ri.Val2, Val3: ri.Val3, Val4: ri.Val4, Val5: ri.Val5, - }) - // Remove from room - room.Items = append(room.Items[:i], room.Items[i+1:]...) - e.notifyRoomChange(RoomChange{RoomNumber: player.RoomNumber, Type: "item_remove", ItemRef: ri.Ref}) - e.SavePlayer(ctx, player) - fullName := e.formatItemName(itemDef, ri.Adj1, ri.Adj2, ri.Adj3) - return &CommandResult{ - Messages: []string{fmt.Sprintf("You pick up %s.", fullName)}, - RoomBroadcast: []string{fmt.Sprintf("%s picks up %s.", player.FirstName, fullName)}, + msgs = append( + msgs, + fmt.Sprintf(" %s (off hand)", name), + ) + } + } + + // Worn equipment. + for _, ii := range player.Worn { + itemDef := e.items[ii.Archetype] + if itemDef != nil { + name := e.formatItemName( + itemDef, + ii.Adj1, + ii.Adj2, + ii.Adj3, + ) + + msgs = append( + msgs, + fmt.Sprintf(" %s (worn)", name), + ) + } + } + + // Normal inventory. + for _, ii := range player.Inventory { + itemDef := e.items[ii.Archetype] + if itemDef != nil { + name := e.formatItemName( + itemDef, + ii.Adj1, + ii.Adj2, + ii.Adj3, + ) + + msgs = append( + msgs, + fmt.Sprintf(" %s", name), + ) + } + } + + return &CommandResult{Messages: msgs} +} + +func (e *GameEngine) doStatus(player *Player) *CommandResult { + recalcBuildPoints(player) + + var msgs []string + + // Organization line (if any) + if player.Organization > 0 { + orgName := organizationName(player.Organization) + if orgName != "" { + msgs = append(msgs, fmt.Sprintf("You are a member of the %s.", orgName)) + } + } + + msgs = append(msgs, + fmt.Sprintf("Name: %s Race: %s Gender: %s Level: %d", player.FullName(), player.RaceName(), genderName(player.Gender), player.Level), + //fmt.Sprintf("Strength: %d Agility: %d Quickness: %d", player.EffectiveStat(StatStrength), player.EffectiveStat(StatAgility), player.EffectiveStat(StatQuickness)), + //fmt.Sprintf("Constitution: %d Perception: %d Willpower: %d Empathy: %d", player.EffectiveStat(StatConstitution), player.EffectiveStat(StatPerception), player.EffectiveStat(StatWillpower), player.EffectiveStat(StatEmpathy)), + fmt.Sprintf("Strength: %s Agility: %s Quickness: %s", + formatEffectiveStat(player, StatStrength, player.Strength), + formatEffectiveStat(player, StatAgility, player.Agility), + formatEffectiveStat(player, StatQuickness, player.Quickness)), + + fmt.Sprintf("Constitution: %s Perception: %s Willpower: %s Empathy: %s", + formatEffectiveStat(player, StatConstitution, player.Constitution), + formatEffectiveStat(player, StatPerception, player.Perception), + formatEffectiveStat(player, StatWillpower, player.Willpower), + formatEffectiveStat(player, StatEmpathy, player.Empathy)), + ) + + spentBP := playerBPSpent(player) + unspentBP := player.BuildPoints + totalBP := unspentBP + spentBP + + xpUntilNextBP := xpUntilNextBuildPoint(player) + + msgs = append(msgs, + fmt.Sprintf("Build Points to date: %d", totalBP), + fmt.Sprintf("Unspent Build Points: %d", unspentBP), + fmt.Sprintf("Experience Points until next Build Point: %d", xpUntilNextBP), + ) + + // Attack/Defense modifiers + var weaponDef *gameworld.ItemDef + if player.Wielded != nil { + weaponDef = e.items[player.Wielded.Archetype] + } + atkRating := playerAttackRating(player, weaponDef) + defRating := e.playerDefenseRating(player) + stanceLabel := stanceNames[player.Stance] + + msgs = append(msgs, + fmt.Sprintf("Current Attack Modifier: %d [%s]", atkRating, stanceLabel), + fmt.Sprintf("Current Defend Modifier: %d", defRating), + ) + + //Room brightness modifiers + room := e.rooms[player.RoomNumber] + + if visStatus := e.visibilityStatus(player, room); visStatus != "" { + msgs = append(msgs, visStatus) + } + + // Height/Weight/Load + heightFeet := player.Height / 12 + heightInches := player.Height % 12 + loadWeight := playerLoadWeight(player, e.items) + msgs = append(msgs, + fmt.Sprintf("Height: %d'%d Weight: %d lbs", heightFeet, heightInches, player.Weight), + fmt.Sprintf("Load: %d lbs", loadWeight), + ) + + // Active temporary effects + now := time.Now() + activeEffectCount := 0 + + for _, effect := range player.ActiveStatEffects { + if !effect.Permanent && !effect.ExpiresAt.After(now) { + continue + } + + if activeEffectCount == 0 { + msgs = append(msgs, "Active Effects:") + } + + name := "Unknown Effect" + + switch effect.Source { + case EffectSourceSpell: + if spell := FindSpellByID(effect.EffectID); spell != nil { + name = spell.Name + } + case EffectSourcePotion: + name = "Potion Effect" + case EffectSourcePoison: + name = "Poison" + case EffectSourceDisease: + name = "Disease" + case EffectSourceItem: + name = "Item Effect" + case EffectSourceScript: + name = "Scripted Effect" + case EffectSourceEncumbrance: + name = "Encumbered" + } + + // Permanent effects + if effect.Permanent { + if effect.Modifier != 0 { + msgs = append( + msgs, + fmt.Sprintf( + " %s: %s%d %s", + name, + modifierSign(effect.Modifier), + effect.Modifier, + statName(effect.Stat), + ), + ) + } else { + msgs = append(msgs, fmt.Sprintf(" %s", name)) } + + activeEffectCount++ + continue + } + + remaining := time.Until(effect.ExpiresAt) + if remaining < 0 { + remaining = 0 + } + + // Numeric stat/status effect + if effect.Modifier != 0 { + msgs = append( + msgs, + fmt.Sprintf( + " %s: %s%d %s — %s remaining", + name, + modifierSign(effect.Modifier), + effect.Modifier, + statName(effect.Stat), + formatEffectDuration(remaining), + ), + ) + } else { + // Non-numeric status effect such as Light, Night Vision, Invisibility, etc. + msgs = append( + msgs, + fmt.Sprintf( + " %s — %s remaining", + name, + formatEffectDuration(remaining), + ), + ) } + + activeEffectCount++ } - return &CommandResult{Messages: []string{"You don't see that here."}} + if activeEffectCount == 0 { + msgs = append(msgs, "Active Effects: None") + } + + return &CommandResult{Messages: msgs} } -func (e *GameEngine) doDrop(ctx context.Context, player *Player, args []string) *CommandResult { +func modifierSign(modifier int) string { + if modifier >= 0 { + return "+" + } + return "" +} + +func statName(stat StatID) string { + switch stat { + case StatStrength: + return "Strength" + case StatAgility: + return "Agility" + case StatQuickness: + return "Quickness" + case StatConstitution: + return "Constitution" + case StatPerception: + return "Perception" + case StatWillpower: + return "Willpower" + case StatEmpathy: + return "Empathy" + case HasteBuff: + return "Haste" + case SlowDebuff: + return "Slow" + case StatBodyPoint: + return "Body Points" + case StatFatigue: + return "Fatigue" + case StatMana: + return "Mana" + case StatPsi: + return "Psioncic Energy" + default: + return "Unknown Stat" + } +} + +func formatEffectDuration(duration time.Duration) string { + duration = duration.Round(time.Second) + + if duration >= time.Minute { + minutes := int(duration / time.Minute) + seconds := int(duration % time.Minute / time.Second) + return fmt.Sprintf("%dm %ds", minutes, seconds) + } + + return fmt.Sprintf("%ds", int(duration/time.Second)) +} +func (e *GameEngine) doHealth(player *Player) *CommandResult { + healthPct := float64(player.BodyPoints) / float64(player.MaxBodyPoints) * 100 + var healthDesc string + switch { + case healthPct >= 100: + healthDesc = "You are in perfect health." + case healthPct >= 75: + healthDesc = "You have minor injuries." + case healthPct >= 50: + healthDesc = "You are moderately wounded." + case healthPct >= 25: + healthDesc = "You are seriously wounded." + case healthPct > 0: + healthDesc = "You are critically wounded!" + default: + healthDesc = "You are dead." + } + return &CommandResult{Messages: []string{ + healthDesc, + fmt.Sprintf("Body: %d/%d Fatigue: %d/%d", player.BodyPoints, player.MaxBodyPoints, player.Fatigue, player.MaxFatigue), + fmt.Sprintf("Mana: %d/%d Psi: %d/%d", player.Mana, player.MaxMana, player.Psi, player.MaxPsi), + }} +} + +func (e *GameEngine) doWield(ctx context.Context, player *Player, args []string) *CommandResult { if len(args) == 0 { - return &CommandResult{Messages: []string{"Drop what?"}} + return &CommandResult{Messages: []string{"Wield what?"}} } + + if player.WolfForm { + return &CommandResult{ + Messages: []string{"You can't wield anything while in wolf form."}, + } + } + target := strings.ToLower(strings.Join(args, " ")) target, ordSkip := parseOrdinal(target) skip := ordSkip + room := e.rooms[player.RoomNumber] if room == nil { return &CommandResult{Messages: []string{"You can't do that here."}} @@ -3073,268 +5790,696 @@ func (e *GameEngine) doDrop(ctx context.Context, player *Player, args []string) if itemDef == nil { continue } + name := e.getItemNounName(itemDef) - if matchesTarget(name, target, e.getAdjName(ii.Adj1)) { - if skip > 0 { skip--; continue } - droppedItem := gameworld.RoomItem{ - Ref: len(room.Items), + if !matchesTarget(name, target, e.getAdjName(ii.Adj1)) { + continue + } + + if skip > 0 { + skip-- + continue + } + + // Wearable non-weapons still route to WEAR. + // Shields are handled here because they occupy the offhand. + if !isShield(itemDef) && + itemDef.WornSlot != "" && + !isWeapon(itemDef.Type) { + return e.doWear(ctx, player, args) + } + + // ------------------------------------------------------------ + // SHIELD -> OFFHAND + // ------------------------------------------------------------ + + if isShield(itemDef) { + if player.Offhand != nil { + offDef := e.items[player.Offhand.Archetype] + offName := "something" + + if offDef != nil { + offName = e.formatItemName( + offDef, + player.Offhand.Adj1, + player.Offhand.Adj2, + player.Offhand.Adj3, + ) + } + + return &CommandResult{ + Messages: []string{ + fmt.Sprintf( + "You are already wielding %s in your off hand.", + offName, + ), + }, + } + } + + // Can't use a shield while wielding a two-handed weapon. + if player.Wielded != nil { + mainDef := e.items[player.Wielded.Archetype] + + if isTwoHandedWeapon(mainDef) { + return &CommandResult{ + Messages: []string{ + "You can't wield a shield while using a two-handed weapon.", + }, + } + } + } + + // Run IFPREVERB WIELD script. + scriptItem := gameworld.RoomItem{ + Ref: -1, Archetype: ii.Archetype, - Adj1: ii.Adj1, Adj2: ii.Adj2, Adj3: ii.Adj3, - Val1: ii.Val1, Val2: ii.Val2, Val3: ii.Val3, Val4: ii.Val4, Val5: ii.Val5, + Adj1: ii.Adj1, + Adj2: ii.Adj2, + Adj3: ii.Adj3, + Val1: ii.Val1, + Val2: ii.Val2, + Val3: ii.Val3, + Val4: ii.Val4, + Val5: ii.Val5, } - room.Items = append(room.Items, droppedItem) - e.notifyRoomChange(RoomChange{RoomNumber: player.RoomNumber, Type: "item_add", Item: &droppedItem}) - player.Inventory = append(player.Inventory[:i], player.Inventory[i+1:]...) + + sc := e.RunPreverbScripts( + player, + room, + "WIELD", + &scriptItem, + itemDef, + ) + + result := &CommandResult{ + Messages: append([]string{}, sc.Messages...), + RoomBroadcast: append([]string{}, sc.RoomMsgs...), + GMBroadcast: append([]string{}, sc.GMMsgs...), + } + + if sc.Blocked { + if len(result.Messages) == 0 { + result.Messages = []string{"You can't wield that."} + } + + e.SavePlayer(ctx, player) + return result + } + + // Move shield from inventory to offhand. + shield := player.Inventory[i] + + player.Inventory = append( + player.Inventory[:i], + player.Inventory[i+1:]..., + ) + + player.Offhand = &shield + e.SavePlayer(ctx, player) - fullName := e.formatItemName(itemDef, ii.Adj1, ii.Adj2, ii.Adj3) + + fullName := e.formatItemName( + itemDef, + shield.Adj1, + shield.Adj2, + shield.Adj3, + ) + + result.Messages = append( + result.Messages, + fmt.Sprintf( + "You wield %s in your off hand.", + fullName, + ), + ) + + result.RoomBroadcast = append( + result.RoomBroadcast, + fmt.Sprintf( + "%s wields %s.", + player.FirstName, + fullName, + ), + ) + + return result + } + + // ------------------------------------------------------------ + // NORMAL WEAPON + // ------------------------------------------------------------ + + if !isWeapon(itemDef.Type) { + return &CommandResult{ + Messages: []string{"You can't wield that."}, + } + } + + // Two-handed weapons require an empty offhand. + if isTwoHandedWeapon(itemDef) && player.Offhand != nil { return &CommandResult{ - Messages: []string{fmt.Sprintf("You drop %s.", fullName)}, - RoomBroadcast: []string{fmt.Sprintf("%s drops %s.", player.FirstName, fullName)}, + Messages: []string{ + "You need both hands free to wield that.", + }, + } + } + + /* + Run the inventory item's IFPREVERB WIELD script before + performing the normal wield action. + */ + scriptItem := gameworld.RoomItem{ + Ref: -1, + Archetype: ii.Archetype, + Adj1: ii.Adj1, + Adj2: ii.Adj2, + Adj3: ii.Adj3, + Val1: ii.Val1, + Val2: ii.Val2, + Val3: ii.Val3, + Val4: ii.Val4, + Val5: ii.Val5, + } + + sc := e.RunPreverbScripts( + player, + room, + "WIELD", + &scriptItem, + itemDef, + ) + + result := &CommandResult{ + Messages: append([]string{}, sc.Messages...), + RoomBroadcast: append([]string{}, sc.RoomMsgs...), + GMBroadcast: append([]string{}, sc.GMMsgs...), + } + + if sc.Blocked { + if len(result.Messages) == 0 { + result.Messages = []string{"You can't wield that."} } + + e.SavePlayer(ctx, player) + return result + } + + // Put currently wielded weapon back into inventory. + if player.Wielded != nil { + player.Inventory = append( + player.Inventory, + *player.Wielded, + ) } + + // Move selected weapon into main hand. + wielded := player.Inventory[i] + + player.Inventory = append( + player.Inventory[:i], + player.Inventory[i+1:]..., + ) + + player.Wielded = &wielded + + e.SavePlayer(ctx, player) + + fullName := e.formatItemName( + itemDef, + wielded.Adj1, + wielded.Adj2, + wielded.Adj3, + ) + + result.Messages = append( + result.Messages, + fmt.Sprintf("You wield %s.", fullName), + ) + + result.RoomBroadcast = append( + result.RoomBroadcast, + fmt.Sprintf( + "%s wields %s.", + player.FirstName, + fullName, + ), + ) + + return result } - return &CommandResult{Messages: []string{"You aren't carrying that."}} + return &CommandResult{ + Messages: []string{"You don't have that."}, + } } -func (e *GameEngine) doInventory(player *Player) *CommandResult { - var msgs []string - msgs = append(msgs, "You are carrying:") - if len(player.Inventory) == 0 && len(player.Worn) == 0 && player.Wielded == nil { - msgs = append(msgs, " Nothing.") - return &CommandResult{Messages: msgs} +func isShield(def *gameworld.ItemDef) bool { + return def != nil && def.Type == "SHIELD" +} + +func isTwoHandedWeapon(def *gameworld.ItemDef) bool { + if def == nil { + return false + } + + switch def.Type { + case "BOW_WEAPON", + "POLE_WEAPON", + "POLETHROWN", + "TWOHAND_WEAPON", + "DRAKIN_POLE": + return true } - if player.Wielded != nil { - itemDef := e.items[player.Wielded.Archetype] - if itemDef != nil { - name := e.formatItemName(itemDef, player.Wielded.Adj1, player.Wielded.Adj2, player.Wielded.Adj3) - msgs = append(msgs, fmt.Sprintf(" %s (wielded)", name)) + return false +} + +func (e *GameEngine) doUnwield(ctx context.Context, player *Player, args []string) *CommandResult { + + if player.WolfForm { + return &CommandResult{ + Messages: []string{"You can't do that while in wolf form."}, } } - for _, ii := range player.Worn { - itemDef := e.items[ii.Archetype] - if itemDef != nil { - name := e.formatItemName(itemDef, ii.Adj1, ii.Adj2, ii.Adj3) - msgs = append(msgs, fmt.Sprintf(" %s (worn)", name)) + if player.Wielded == nil && player.Offhand == nil { + return &CommandResult{ + Messages: []string{"You aren't wielding anything."}, } } - for _, ii := range player.Inventory { - itemDef := e.items[ii.Archetype] - if itemDef != nil { - name := e.formatItemName(itemDef, ii.Adj1, ii.Adj2, ii.Adj3) - msgs = append(msgs, fmt.Sprintf(" %s", name)) + room := e.rooms[player.RoomNumber] + if room == nil { + return &CommandResult{ + Messages: []string{"You can't do that here."}, } } - return &CommandResult{Messages: msgs} -} + var item *InventoryItem + var itemDef *gameworld.ItemDef + isOffhand := false -func (e *GameEngine) doStatus(player *Player) *CommandResult { - recalcBuildPoints(player) + // ------------------------------------------------------------ + // Determine which wielded item to remove. + // ------------------------------------------------------------ - var msgs []string + if len(args) > 0 { + target := strings.ToLower(strings.Join(args, " ")) + target, _ = parseOrdinal(target) - // Organization line (if any) - if player.Organization > 0 { - orgName := organizationName(player.Organization) - if orgName != "" { - msgs = append(msgs, fmt.Sprintf("You are a member of the %s.", orgName)) + // Check main hand. + if player.Wielded != nil { + def := e.items[player.Wielded.Archetype] + + if def != nil { + name := e.getItemNounName(def) + + if matchesTarget( + name, + target, + e.getAdjName(player.Wielded.Adj1), + ) { + item = player.Wielded + itemDef = def + } + } } - } - msgs = append(msgs, - fmt.Sprintf("Name: %s Race: %s Gender: %s Level: %d", player.FullName(), player.RaceName(), genderName(player.Gender), player.Level), - fmt.Sprintf("Strength: %d Agility: %d Quickness: %d", player.Strength, player.Agility, player.Quickness), - fmt.Sprintf("Constitution: %d Perception: %d Willpower: %d Empathy: %d", player.Constitution, player.Perception, player.Willpower, player.Empathy), + // If it wasn't the main hand, check offhand. + if item == nil && player.Offhand != nil { + def := e.items[player.Offhand.Archetype] + + if def != nil { + name := e.getItemNounName(def) + + if matchesTarget( + name, + target, + e.getAdjName(player.Offhand.Adj1), + ) { + item = player.Offhand + itemDef = def + isOffhand = true + } + } + } + + if item == nil { + return &CommandResult{ + Messages: []string{"You aren't wielding that."}, + } + } + } else { + // No target specified: + // main hand first, then offhand. + if player.Wielded != nil { + item = player.Wielded + itemDef = e.items[item.Archetype] + } else { + item = player.Offhand + itemDef = e.items[item.Archetype] + isOffhand = true + } + } + + // ------------------------------------------------------------ + // Run IFPREVERB UNWIELD script. + // ------------------------------------------------------------ + + scriptItem := gameworld.RoomItem{ + Ref: -1, + Archetype: item.Archetype, + Adj1: item.Adj1, + Adj2: item.Adj2, + Adj3: item.Adj3, + Val1: item.Val1, + Val2: item.Val2, + Val3: item.Val3, + Val4: item.Val4, + Val5: item.Val5, + } + + sc := e.RunPreverbScripts( + player, + room, + "UNWIELD", + &scriptItem, + itemDef, ) - // Build points - totalBP := player.BuildPoints - spentBP := playerBPSpent(player) - unspentBP := totalBP - spentBP - if unspentBP < 0 { - unspentBP = 0 + result := &CommandResult{ + Messages: append([]string{}, sc.Messages...), + RoomBroadcast: append([]string{}, sc.RoomMsgs...), + GMBroadcast: append([]string{}, sc.GMMsgs...), } - xpUntilNextBP := xpUntilNextBuildPoint(player) - msgs = append(msgs, - fmt.Sprintf("Build Points to date: %d", totalBP), - fmt.Sprintf("Unspent Build Points: %d", unspentBP), - fmt.Sprintf("Experience Points until next Build Point: %d", xpUntilNextBP), + if sc.Blocked { + if len(result.Messages) == 0 { + result.Messages = []string{"You can't put that away."} + } + + e.SavePlayer(ctx, player) + return result + } + + // ------------------------------------------------------------ + // Format the item name before clearing the slot. + // ------------------------------------------------------------ + + itemName := "your weapon" + + if itemDef != nil { + itemName = e.formatItemName( + itemDef, + item.Adj1, + item.Adj2, + item.Adj3, + ) + } + + // ------------------------------------------------------------ + // Move the item back to inventory. + // ------------------------------------------------------------ + + player.Inventory = append( + player.Inventory, + *item, ) - // Attack/Defense modifiers - var weaponDef *gameworld.ItemDef - if player.Wielded != nil { - weaponDef = e.items[player.Wielded.Archetype] + if isOffhand { + player.Offhand = nil + } else { + player.Wielded = nil } - atkRating := playerAttackRating(player, weaponDef) - defRating := playerDefenseRating(player) - stanceLabel := stanceNames[player.Stance] - msgs = append(msgs, - fmt.Sprintf("Current Attack Modifier: %d [%s]", atkRating, stanceLabel), - fmt.Sprintf("Current Defend Modifier: %d", defRating), + e.SavePlayer(ctx, player) + + result.Messages = append( + result.Messages, + fmt.Sprintf("You put away %s.", itemName), ) - // Height/Weight/Load - heightFeet := player.Height / 12 - heightInches := player.Height % 12 - loadWeight := playerLoadWeight(player, e.items) - msgs = append(msgs, - fmt.Sprintf("Height: %d'%d Weight: %d lbs", heightFeet, heightInches, player.Weight), - fmt.Sprintf("Load: %d lbs", loadWeight), + result.RoomBroadcast = append( + result.RoomBroadcast, + fmt.Sprintf( + "%s puts away %s.", + player.FirstName, + itemName, + ), ) - return &CommandResult{Messages: msgs} + return result } - -func (e *GameEngine) doHealth(player *Player) *CommandResult { - healthPct := float64(player.BodyPoints) / float64(player.MaxBodyPoints) * 100 - var healthDesc string - switch { - case healthPct >= 100: - healthDesc = "You are in perfect health." - case healthPct >= 75: - healthDesc = "You have minor injuries." - case healthPct >= 50: - healthDesc = "You are moderately wounded." - case healthPct >= 25: - healthDesc = "You are seriously wounded." - case healthPct > 0: - healthDesc = "You are critically wounded!" - default: - healthDesc = "You are dead." +func (e *GameEngine) doWear(ctx context.Context, player *Player, args []string) *CommandResult { + if len(args) == 0 { + return &CommandResult{Messages: []string{"Wear what?"}} } - return &CommandResult{Messages: []string{ - healthDesc, - fmt.Sprintf("Body: %d/%d Fatigue: %d/%d", player.BodyPoints, player.MaxBodyPoints, player.Fatigue, player.MaxFatigue), - fmt.Sprintf("Mana: %d/%d Psi: %d/%d", player.Mana, player.MaxMana, player.Psi, player.MaxPsi), - }} -} -func (e *GameEngine) doWield(ctx context.Context, player *Player, args []string) *CommandResult { - if len(args) == 0 { - return &CommandResult{Messages: []string{"Wield what?"}} + if player.WolfForm { + return &CommandResult{ + Messages: []string{"You can't wear anything while in wolf form."}, + } } target := strings.ToLower(strings.Join(args, " ")) target, ordSkip := parseOrdinal(target) skip := ordSkip + + room := e.rooms[player.RoomNumber] + if room == nil { + return &CommandResult{Messages: []string{"You can't do that here."}} + } + for i, ii := range player.Inventory { itemDef := e.items[ii.Archetype] if itemDef == nil { continue } + + if itemDef.WornSlot == "" { + continue + } + name := e.getItemNounName(itemDef) if !matchesTarget(name, target, e.getAdjName(ii.Adj1)) { continue } - if skip > 0 { skip--; continue } - // Shields are worn, not wielded — route to WEAR - if itemDef.Type == "SHIELD" || (itemDef.WornSlot != "" && !isWeapon(itemDef.Type)) { - return e.doWear(ctx, player, args) + + if skip > 0 { + skip-- + continue } - if !isWeapon(itemDef.Type) { - return &CommandResult{Messages: []string{"You can't wield that."}} + + // Run the inventory item's IFPREVERB WEAR script before + // performing the normal wear action. + scriptItem := gameworld.RoomItem{ + Ref: -1, + Archetype: ii.Archetype, + Adj1: ii.Adj1, + Adj2: ii.Adj2, + Adj3: ii.Adj3, + Val1: ii.Val1, + Val2: ii.Val2, + Val3: ii.Val3, + Val4: ii.Val4, + Val5: ii.Val5, + } + + sc := e.RunPreverbScripts( + player, + room, + "WEAR", + &scriptItem, + itemDef, + ) + + result := &CommandResult{ + Messages: append([]string{}, sc.Messages...), + RoomBroadcast: append([]string{}, sc.RoomMsgs...), + GMBroadcast: append([]string{}, sc.GMMsgs...), } - if player.Wielded != nil { - player.Inventory = append(player.Inventory, *player.Wielded) + + // CLEARVERB cancels the normal wear action. + if sc.Blocked { + if len(result.Messages) == 0 { + result.Messages = []string{"You can't wear that."} + } + + e.SavePlayer(ctx, player) + return result } - wielded := player.Inventory[i] - player.Inventory = append(player.Inventory[:i], player.Inventory[i+1:]...) - player.Wielded = &wielded - e.SavePlayer(ctx, player) - fullName := e.formatItemName(itemDef, ii.Adj1, ii.Adj2, ii.Adj3) - return &CommandResult{ - Messages: []string{fmt.Sprintf("You wield %s.", fullName)}, - RoomBroadcast: []string{fmt.Sprintf("%s wields %s.", player.FirstName, fullName)}, + + for _, wi := range player.Worn { + if wi.WornSlot == itemDef.WornSlot { + wornDef := e.items[wi.Archetype] + + wornName := "something" + if wornDef != nil { + wornName = e.formatItemName( + wornDef, + wi.Adj1, + wi.Adj2, + wi.Adj3, + ) + } + + return &CommandResult{ + Messages: []string{ + fmt.Sprintf( + "You are already wearing %s there.", + wornName, + ), + }, + } + } } - } - return &CommandResult{Messages: []string{"You don't have that."}} -} + worn := player.Inventory[i] + worn.WornSlot = itemDef.WornSlot -func (e *GameEngine) doUnwield(ctx context.Context, player *Player) *CommandResult { - if player.Wielded == nil { - return &CommandResult{Messages: []string{"You aren't wielding anything."}} - } - itemDef := e.items[player.Wielded.Archetype] - wepName := "their weapon" - if itemDef != nil { - wepName = e.formatItemName(itemDef, player.Wielded.Adj1, player.Wielded.Adj2, player.Wielded.Adj3) + player.Inventory = append( + player.Inventory[:i], + player.Inventory[i+1:]..., + ) + + player.Worn = append(player.Worn, worn) + + e.SavePlayer(ctx, player) + + fullName := e.formatItemName( + itemDef, + ii.Adj1, + ii.Adj2, + ii.Adj3, + ) + + result.Messages = append( + result.Messages, + fmt.Sprintf("You put on %s.", fullName), + ) + + result.RoomBroadcast = append( + result.RoomBroadcast, + fmt.Sprintf("%s puts on %s.", player.FirstName, fullName), + ) + + return result } - player.Inventory = append(player.Inventory, *player.Wielded) - player.Wielded = nil - e.SavePlayer(ctx, player) + return &CommandResult{ - Messages: []string{"You put away your weapon."}, - RoomBroadcast: []string{fmt.Sprintf("%s puts away %s.", player.FirstName, wepName)}, + Messages: []string{"You don't have that."}, } } -func (e *GameEngine) doWear(ctx context.Context, player *Player, args []string) *CommandResult { +func (e *GameEngine) doRemove(ctx context.Context, player *Player, args []string) *CommandResult { if len(args) == 0 { - return &CommandResult{Messages: []string{"Wear what?"}} + return &CommandResult{Messages: []string{"Remove what?"}} } - target := strings.ToLower(strings.Join(args, " ")) - target, ordSkip := parseOrdinal(target) - skip := ordSkip - for i, ii := range player.Inventory { - itemDef := e.items[ii.Archetype] - if itemDef == nil { - continue - } - if itemDef.WornSlot == "" { - continue - } - name := e.getItemNounName(itemDef) - if matchesTarget(name, target, e.getAdjName(ii.Adj1)) { - if skip > 0 { skip--; continue } - worn := player.Inventory[i] - worn.WornSlot = itemDef.WornSlot - player.Inventory = append(player.Inventory[:i], player.Inventory[i+1:]...) - player.Worn = append(player.Worn, worn) - e.SavePlayer(ctx, player) - fullName := e.formatItemName(itemDef, ii.Adj1, ii.Adj2, ii.Adj3) - return &CommandResult{ - Messages: []string{fmt.Sprintf("You put on %s.", fullName)}, - RoomBroadcast: []string{fmt.Sprintf("%s puts on %s.", player.FirstName, fullName)}, - } + + if player.WolfForm { + return &CommandResult{ + Messages: []string{"You can't do that while in wolf form."}, } } - return &CommandResult{Messages: []string{"You don't have that."}} -} -func (e *GameEngine) doRemove(ctx context.Context, player *Player, args []string) *CommandResult { - if len(args) == 0 { - return &CommandResult{Messages: []string{"Remove what?"}} - } target := strings.ToLower(strings.Join(args, " ")) target, ordSkip := parseOrdinal(target) skip := ordSkip + + room := e.rooms[player.RoomNumber] + if room == nil { + return &CommandResult{Messages: []string{"You can't do that here."}} + } + for i, ii := range player.Worn { itemDef := e.items[ii.Archetype] if itemDef == nil { continue } + name := e.getItemNounName(itemDef) - if matchesTarget(name, target, e.getAdjName(ii.Adj1)) { - if skip > 0 { skip--; continue } - removed := player.Worn[i] - removed.WornSlot = "" - player.Worn = append(player.Worn[:i], player.Worn[i+1:]...) - player.Inventory = append(player.Inventory, removed) - e.SavePlayer(ctx, player) - fullName := e.formatItemName(itemDef, ii.Adj1, ii.Adj2, ii.Adj3) - return &CommandResult{ - Messages: []string{fmt.Sprintf("You remove %s.", fullName)}, - RoomBroadcast: []string{fmt.Sprintf("%s removes %s.", player.FirstName, fullName)}, + if !matchesTarget(name, target, e.getAdjName(ii.Adj1)) { + continue + } + + if skip > 0 { + skip-- + continue + } + + // Run the worn item's IFPREVERB REMOVE script before + // performing the normal remove action. + scriptItem := gameworld.RoomItem{ + Ref: -1, + Archetype: ii.Archetype, + Adj1: ii.Adj1, + Adj2: ii.Adj2, + Adj3: ii.Adj3, + Val1: ii.Val1, + Val2: ii.Val2, + Val3: ii.Val3, + Val4: ii.Val4, + Val5: ii.Val5, + } + + sc := e.RunPreverbScripts( + player, + room, + "REMOVE", + &scriptItem, + itemDef, + ) + + result := &CommandResult{ + Messages: append([]string{}, sc.Messages...), + RoomBroadcast: append([]string{}, sc.RoomMsgs...), + GMBroadcast: append([]string{}, sc.GMMsgs...), + } + + // CLEARVERB cancels the normal remove action. + if sc.Blocked { + if len(result.Messages) == 0 { + result.Messages = []string{"You can't remove that."} } + + e.SavePlayer(ctx, player) + return result } + + removed := player.Worn[i] + removed.WornSlot = "" + + player.Worn = append( + player.Worn[:i], + player.Worn[i+1:]..., + ) + + player.Inventory = append(player.Inventory, removed) + + e.SavePlayer(ctx, player) + + fullName := e.formatItemName( + itemDef, + ii.Adj1, + ii.Adj2, + ii.Adj3, + ) + + result.Messages = append( + result.Messages, + fmt.Sprintf("You remove %s.", fullName), + ) + + result.RoomBroadcast = append( + result.RoomBroadcast, + fmt.Sprintf("%s removes %s.", player.FirstName, fullName), + ) + + return result + } + + return &CommandResult{ + Messages: []string{"You aren't wearing that."}, } - return &CommandResult{Messages: []string{"You aren't wearing that."}} } func (e *GameEngine) doOpen(player *Player, args []string) *CommandResult { @@ -3355,7 +6500,10 @@ func (e *GameEngine) doOpen(player *Player, args []string) *CommandResult { } name := e.getItemNounName(itemDef) if matchesTarget(name, target, e.getAdjName(ri.Adj1)) { - if skip > 0 { skip--; continue } + if skip > 0 { + skip-- + continue + } if !containsFlag(itemDef.Flags, "OPENABLE") && !isPortal(itemDef.Type) { return &CommandResult{Messages: []string{"You can't open that."}} } @@ -3386,10 +6534,26 @@ func (e *GameEngine) doOpen(player *Player, args []string) *CommandResult { } name := e.getItemNounName(itemDef) if matchesTarget(name, target, e.getAdjName(ii.Adj1)) { - if skip > 0 { skip--; continue } + if skip > 0 { + skip-- + continue + } if !containsFlag(itemDef.Flags, "OPENABLE") { return &CommandResult{Messages: []string{"You can't open that."}} } + + if ii.State == "LOCKED" { + return &CommandResult{ + Messages: []string{"It's locked."}, + } + } + + if ii.State == "LATCHED" { + return &CommandResult{ + Messages: []string{"It's latched shut."}, + } + } + player.Inventory[i].State = "OPEN" fullName := e.formatItemName(itemDef, ii.Adj1, ii.Adj2, ii.Adj3) return &CommandResult{Messages: []string{fmt.Sprintf("You open %s.", fullName)}} @@ -3411,48 +6575,66 @@ func (e *GameEngine) checkTrap(player *Player, ri *gameworld.RoomItem) []string case trapType == 1: // Needle, minor poison msgs = append(msgs, "A needle springs out and pricks your finger!") player.Poisoned = true + player.ApplyStatEffect(ri.Val4, EffectSourcePoison, StatBodyPoint, PoisonMinor, time.Duration(PoisonMinor)*time.Minute) case trapType == 2: // Gas, minor poison msgs = append(msgs, "A cloud of noxious gas billows out!") player.Poisoned = true + player.ApplyStatEffect(ri.Val4, EffectSourcePoison, StatBodyPoint, PoisonNerveGas, time.Duration(PoisonNerveGas)*time.Minute) case trapType == 3: // Acid dmg := 10 + rand.Intn(15) player.BodyPoints -= dmg - if player.BodyPoints < 0 { player.BodyPoints = 0 } + if player.BodyPoints < 0 { + player.BodyPoints = 0 + } msgs = append(msgs, fmt.Sprintf("Acid sprays out! [%d Damage]", dmg)) case trapType == 4: // Blades dmg := 15 + rand.Intn(20) player.BodyPoints -= dmg - if player.BodyPoints < 0 { player.BodyPoints = 0 } + if player.BodyPoints < 0 { + player.BodyPoints = 0 + } msgs = append(msgs, fmt.Sprintf("Hidden blades slash at you! [%d Damage]", dmg)) case trapType == 5: // Needle, moderate poison msgs = append(msgs, "A poison-coated needle jabs into your hand!") player.Poisoned = true + player.ApplyStatEffect(ri.Val4, EffectSourcePoison, StatBodyPoint, PoisonModerate, time.Duration(PoisonModerate)*time.Minute) case trapType == 7: // Needle, major poison msgs = append(msgs, "A large needle drives deep into your finger, delivering a potent venom!") player.Poisoned = true + player.ApplyStatEffect(ri.Val4, EffectSourcePoison, StatBodyPoint, PoisonMajor, time.Duration(PoisonMajor)*time.Minute) case trapType == 8: // Explosive dmg := 30 + rand.Intn(30) player.BodyPoints -= dmg - if player.BodyPoints < 0 { player.BodyPoints = 0 } + if player.BodyPoints < 0 { + player.BodyPoints = 0 + } msgs = append(msgs, fmt.Sprintf("The container explodes! [%d Damage]", dmg)) case trapType == 9: // Acid, moderate dmg := 20 + rand.Intn(25) player.BodyPoints -= dmg - if player.BodyPoints < 0 { player.BodyPoints = 0 } + if player.BodyPoints < 0 { + player.BodyPoints = 0 + } msgs = append(msgs, fmt.Sprintf("A gout of acid sprays out! [%d Damage]", dmg)) case trapType == 12: // Gas, moderate poison msgs = append(msgs, "A thick cloud of poisonous gas engulfs you!") player.Poisoned = true + player.ApplyStatEffect(ri.Val4, EffectSourcePoison, StatBodyPoint, PoisonModerate, time.Duration(PoisonModerate)*time.Minute) case trapType == 13: // Black needle, lethal dmg := 40 + rand.Intn(30) player.BodyPoints -= dmg - if player.BodyPoints < 0 { player.BodyPoints = 0 } + if player.BodyPoints < 0 { + player.BodyPoints = 0 + } msgs = append(msgs, fmt.Sprintf("A black needle strikes you, delivering a lethal toxin! [%d Damage]", dmg)) player.Poisoned = true + player.ApplyStatEffect(ri.Val4, EffectSourcePoison, StatBodyPoint, PoisonLethal, time.Duration(PoisonLethal)*time.Minute) case trapType >= 1000: // Glyph traps (spell-based) spellDmg := 20 + rand.Intn(40) player.BodyPoints -= spellDmg - if player.BodyPoints < 0 { player.BodyPoints = 0 } + if player.BodyPoints < 0 { + player.BodyPoints = 0 + } glyphType := (trapType / 1000) % 10 switch { case glyphType <= 2: @@ -3489,7 +6671,10 @@ func (e *GameEngine) doClose(player *Player, args []string) *CommandResult { } name := e.getItemNounName(itemDef) if matchesTarget(name, target, e.getAdjName(ri.Adj1)) { - if skip > 0 { skip--; continue } + if skip > 0 { + skip-- + continue + } room.Items[i].State = "CLOSED" e.notifyRoomChange(RoomChange{RoomNumber: player.RoomNumber, Type: "item_state", ItemRef: ri.Ref, NewState: "CLOSED"}) fullName := e.formatItemName(itemDef, ri.Adj1, ri.Adj2, ri.Adj3) @@ -3504,7 +6689,10 @@ func (e *GameEngine) doClose(player *Player, args []string) *CommandResult { } name := e.getItemNounName(itemDef) if matchesTarget(name, target, e.getAdjName(ii.Adj1)) { - if skip > 0 { skip--; continue } + if skip > 0 { + skip-- + continue + } player.Inventory[i].State = "CLOSED" fullName := e.formatItemName(itemDef, ii.Adj1, ii.Adj2, ii.Adj3) return &CommandResult{Messages: []string{fmt.Sprintf("You close %s.", fullName)}} @@ -3676,7 +6864,6 @@ func (e *GameEngine) doChant(ctx context.Context, player *Player, args []string) return &CommandResult{Messages: []string{"Chant what?"}} } target := strings.ToLower(strings.Join(args, " ")) - // Strip "my " prefix target = strings.TrimPrefix(target, "my ") target, ordSkip := parseOrdinal(target) skip := ordSkip @@ -3690,24 +6877,43 @@ func (e *GameEngine) doChant(ctx context.Context, player *Player, args []string) continue } name := e.getItemNounName(itemDef) - if matchesTarget(name, target, e.getAdjName(ii.Adj1)) { - if skip > 0 { - skip-- - continue - } - fullName := e.formatItemName(itemDef, ii.Adj1, ii.Adj2, ii.Adj3) - // Remove the scroll from inventory - player.Inventory = append(player.Inventory[:i], player.Inventory[i+1:]...) - player.RoundTimeExpiry = time.Now().Add(3 * time.Second) - e.SavePlayer(ctx, player) - return &CommandResult{ - Messages: []string{ - fmt.Sprintf("As you chant the scroll, it crumbles into dust..."), - "You feel the power of the scroll flow into you.", - "[Round: 3 sec]", - }, - RoomBroadcast: []string{fmt.Sprintf("%s chants from %s which crumbles into dust.", player.FirstName, fullName)}, - } + if !matchesTarget(name, target, e.getAdjName(ii.Adj1)) { + continue + } + if skip > 0 { + skip-- + continue + } + + spellNum := ii.Val3 + if spellNum == 0 { + return &CommandResult{Messages: []string{"This scroll holds no magical inscription."}} + } + + spell := FindSpellByID(spellNum) + if spell == nil { + return &CommandResult{Messages: []string{"The scroll's magic is indecipherable."}} + } + + fullName := e.formatItemName(itemDef, ii.Adj1, ii.Adj2, ii.Adj3) + + // Consume the scroll + player.Inventory = append(player.Inventory[:i], player.Inventory[i+1:]...) + + // Prepare the spell — match the field name used by doPrepareSpell + player.PreparedSpell = spellNum // ← adjust field name to match your Player struct + + player.RoundTimeExpiry = time.Now().Add(3 * time.Second) + e.SavePlayer(ctx, player) + return &CommandResult{ + Messages: []string{ + fmt.Sprintf("As you chant %s, it crumbles into dust...", fullName), + fmt.Sprintf("The power of %s flows into you. The spell is prepared.", spell.Name), + "[Round: 3 sec]", + }, + RoomBroadcast: []string{ + fmt.Sprintf("%s chants from %s which crumbles into dust.", player.FirstName, fullName), + }, } } return &CommandResult{Messages: []string{"You don't have that."}} @@ -3784,10 +6990,10 @@ func (e *GameEngine) moveGroupToRoom(ctx context.Context, srcRoom, destRoom int) p.Submitting = false e.disengageCombat(p) e.SavePlayer(ctx, p) - if e.sendToPlayer != nil { - lookResult := e.doLook(p) - e.sendToPlayer(p.FirstName, lookResult.Messages) - } + // if e.sendToPlayer != nil { + // lookResult := e.doLook(p) + // e.sendToPlayer(p.FirstName, lookResult.Messages) + // } e.applyEntryScripts(ctx, p, dest, &CommandResult{}) } } @@ -3859,9 +7065,12 @@ func (e *GameEngine) doDisband(player *Player) *CommandResult { func (e *GameEngine) doGive(ctx context.Context, player *Player, args []string) *CommandResult { if len(args) < 2 { - return &CommandResult{Messages: []string{"Give what to whom? (give to )"}} + return &CommandResult{ + Messages: []string{"Give what to whom? (give to )"}, + } } - // Parse: give to OR give + + // Parse: give to toIdx := -1 for i, a := range args { if strings.ToUpper(a) == "TO" { @@ -3869,55 +7078,156 @@ func (e *GameEngine) doGive(ctx context.Context, player *Player, args []string) break } } + var itemName, targetName string if toIdx > 0 && toIdx < len(args)-1 { itemName = strings.ToLower(strings.Join(args[:toIdx], " ")) targetName = strings.ToLower(strings.Join(args[toIdx+1:], " ")) } else { - return &CommandResult{Messages: []string{"Give what to whom? (give to )"}} + return &CommandResult{ + Messages: []string{"Give what to whom? (give to )"}, + } } - // Check for money giving: "give 5 gold to Taliesin", "give 10 kragenmark to Taliesin" + + // Money giving does not run item scripts. if amount, currency, ok := parseMoneyAmount(itemName); ok { target := e.findPlayerInRoom(player, targetName) if target == nil { - return &CommandResult{Messages: []string{"You don't see that person here."}} + return &CommandResult{ + Messages: []string{"You don't see that person here."}, + } } + return e.doGiveMoney(ctx, player, target, amount, currency) } itemName, ordSkip := parseOrdinal(itemName) skip := ordSkip - // Find the item in inventory + room := e.rooms[player.RoomNumber] + if room == nil { + return &CommandResult{ + Messages: []string{"You can't do that here."}, + } + } + + // Find the item in inventory. for i, ii := range player.Inventory { itemDef := e.items[ii.Archetype] if itemDef == nil { continue } + name := e.getItemNounName(itemDef) if !matchesTarget(name, itemName, e.getAdjName(ii.Adj1)) { continue } - if skip > 0 { skip--; continue } - // Find the target player + + if skip > 0 { + skip-- + continue + } + + // Find the target before running the item script. target := e.findPlayerInRoom(player, targetName) if target == nil { - return &CommandResult{Messages: []string{"You don't see that person here."}} + return &CommandResult{ + Messages: []string{"You don't see that person here."}, + } } - // Transfer item - fullName := e.formatItemName(itemDef, ii.Adj1, ii.Adj2, ii.Adj3) + + // Run the inventory item's IFPREVERB GIVE script before + // performing the normal transfer. + scriptItem := gameworld.RoomItem{ + Ref: -1, + Archetype: ii.Archetype, + Adj1: ii.Adj1, + Adj2: ii.Adj2, + Adj3: ii.Adj3, + Val1: ii.Val1, + Val2: ii.Val2, + Val3: ii.Val3, + Val4: ii.Val4, + Val5: ii.Val5, + } + + sc := e.RunPreverbScripts( + player, + room, + "GIVE", + &scriptItem, + itemDef, + ) + + result := &CommandResult{ + Messages: append([]string{}, sc.Messages...), + RoomBroadcast: append([]string{}, sc.RoomMsgs...), + GMBroadcast: append([]string{}, sc.GMMsgs...), + } + + // CLEARVERB cancels the normal GIVE action. + if sc.Blocked { + if len(result.Messages) == 0 { + result.Messages = []string{"You can't give that away."} + } + + e.SavePlayer(ctx, player) + return result + } + + fullName := e.formatItemName( + itemDef, + ii.Adj1, + ii.Adj2, + ii.Adj3, + ) + + // Transfer the item. target.Inventory = append(target.Inventory, ii) - player.Inventory = append(player.Inventory[:i], player.Inventory[i+1:]...) + + player.Inventory = append( + player.Inventory[:i], + player.Inventory[i+1:]..., + ) + e.SavePlayer(ctx, player) e.SavePlayer(ctx, target) - return &CommandResult{ - Messages: []string{fmt.Sprintf("You give %s to %s.", fullName, target.FirstName)}, - RoomBroadcast: []string{fmt.Sprintf("%s gives %s to %s.", player.FirstName, fullName, target.FirstName)}, - TargetName: target.FirstName, - TargetMsg: []string{fmt.Sprintf("%s gives you %s.", player.FirstName, fullName)}, - } + + result.Messages = append( + result.Messages, + fmt.Sprintf( + "You give %s to %s.", + fullName, + target.FirstName, + ), + ) + + result.RoomBroadcast = append( + result.RoomBroadcast, + fmt.Sprintf( + "%s gives %s to %s.", + player.FirstName, + fullName, + target.FirstName, + ), + ) + + result.TargetName = target.FirstName + result.TargetMsg = append( + result.TargetMsg, + fmt.Sprintf( + "%s gives you %s.", + player.FirstName, + fullName, + ), + ) + + return result + } + + return &CommandResult{ + Messages: []string{"You don't have that."}, } - return &CommandResult{Messages: []string{"You don't have that."}} } // parseMoneyAmount checks if a string like "5 gold" or "10 kragenmark" is a money amount. @@ -3968,7 +7278,9 @@ func (e *GameEngine) doGiveMoney(ctx context.Context, giver, receiver *Player, a giver.Gold -= amount receiver.Gold += amount currencyDisplay = fmt.Sprintf("%d gold crown", amount) - if amount != 1 { currencyDisplay += "s" } + if amount != 1 { + currencyDisplay += "s" + } case "silver": if giver.Silver < amount { return &CommandResult{Messages: []string{fmt.Sprintf("You only have %d silver.", giver.Silver)}} @@ -3976,7 +7288,9 @@ func (e *GameEngine) doGiveMoney(ctx context.Context, giver, receiver *Player, a giver.Silver -= amount receiver.Silver += amount currencyDisplay = fmt.Sprintf("%d silver shilling", amount) - if amount != 1 { currencyDisplay += "s" } + if amount != 1 { + currencyDisplay += "s" + } case "copper": if giver.Copper < amount { return &CommandResult{Messages: []string{fmt.Sprintf("You only have %d copper.", giver.Copper)}} @@ -3984,7 +7298,11 @@ func (e *GameEngine) doGiveMoney(ctx context.Context, giver, receiver *Player, a giver.Copper -= amount receiver.Copper += amount currencyDisplay = fmt.Sprintf("%d copper penn", amount) - if amount == 1 { currencyDisplay += "y" } else { currencyDisplay += "ies" } + if amount == 1 { + currencyDisplay += "y" + } else { + currencyDisplay += "ies" + } default: // Regional currencies — these are handled as inventory items with MONEY type // Find the currency item in giver's inventory @@ -4011,7 +7329,9 @@ func (e *GameEngine) doGiveMoney(ctx context.Context, giver, receiver *Player, a receiver.Inventory = append(receiver.Inventory, newItem) } currencyDisplay = fmt.Sprintf("%d %s", amount, currency) - if amount != 1 { currencyDisplay += "s" } + if amount != 1 { + currencyDisplay += "s" + } e.SavePlayer(ctx, giver) e.SavePlayer(ctx, receiver) return &CommandResult{ @@ -4052,7 +7372,10 @@ func (e *GameEngine) doEat(ctx context.Context, player *Player, args []string) * } name := e.getItemNounName(itemDef) if matchesTarget(name, target, e.getAdjName(ii.Adj1)) { - if skip > 0 { skip--; continue } + if skip > 0 { + skip-- + continue + } fullName := e.formatItemName(itemDef, ii.Adj1, ii.Adj2, ii.Adj3) // Run item scripts FIRST — they may set ITEMVAL3 based on adjective checks @@ -4099,7 +7422,11 @@ func (e *GameEngine) doEat(ctx context.Context, player *Player, args []string) * } else if spellNum != 0 { msgs = append(msgs, fmt.Sprintf("[Spell #%d effect coming soon.]", spellNum)) } + } else if spellNum != 0 { + msgs = append(msgs, fmt.Sprintf("[Spell #%d effect coming soon.]", spellNum)) + } + e.SavePlayer(ctx, player) return &CommandResult{ Messages: msgs, @@ -4134,8 +7461,8 @@ func (e *GameEngine) doInfo(player *Player) *CommandResult { fmt.Sprintf("Name: %s", player.FullName()), fmt.Sprintf("Race: %s Gender: %s Level: %d", player.RaceName(), genderName(player.Gender), player.Level), "", - fmt.Sprintf("Strength: %-3d Agility: %-3d Quickness: %d", player.Strength, player.Agility, player.Quickness), - fmt.Sprintf("Constitution: %-3d Perception: %-3d Willpower: %-3d Empathy: %d", player.Constitution, player.Perception, player.Willpower, player.Empathy), + fmt.Sprintf("Strength: %-3d Agility: %-3d Quickness: %d", player.EffectiveStat(StatStrength), player.EffectiveStat(StatAgility), player.EffectiveStat(StatQuickness)), + fmt.Sprintf("Constitution: %-3d Perception: %-3d Willpower: %-3d Empathy: %d", player.EffectiveStat(StatConstitution), player.EffectiveStat(StatPerception), player.EffectiveStat(StatWillpower), player.EffectiveStat(StatEmpathy)), "", fmt.Sprintf("Body Points: %d/%d Fatigue: %d/%d", player.BodyPoints, player.MaxBodyPoints, player.Fatigue, player.MaxFatigue), fmt.Sprintf("Mana: %d/%d Psi: %d/%d", player.Mana, player.MaxMana, player.Psi, player.MaxPsi), @@ -4169,7 +7496,10 @@ func (e *GameEngine) doBuy(ctx context.Context, player *Player, args []string) * if !matchesTarget(name, target, adjName) { continue } - if skip > 0 { skip--; continue } + if skip > 0 { + skip-- + continue + } // Check affordability totalCopper := player.Gold*100 + player.Silver*10 + player.Copper @@ -4255,7 +7585,10 @@ func (e *GameEngine) doSell(ctx context.Context, player *Player, args []string) } name := e.getItemNounName(itemDef) if matchesTarget(name, target, e.getAdjName(ii.Adj1)) { - if skip > 0 { skip--; continue } + if skip > 0 { + skip-- + continue + } displayName := e.formatItemName(itemDef, ii.Adj1, ii.Adj2, ii.Adj3) // Sell value: VAL1 on item is copper value, fallback to weight-based estimate sellValue := ii.Val1 @@ -4264,7 +7597,9 @@ func (e *GameEngine) doSell(ctx context.Context, player *Player, args []string) } // Merchants pay ~50% of value sellValue = sellValue / 2 - if sellValue < 1 { sellValue = 1 } + if sellValue < 1 { + sellValue = 1 + } player.Inventory = append(player.Inventory[:i], player.Inventory[i+1:]...) // Add coins player.Gold += sellValue / 100 @@ -4309,14 +7644,19 @@ func (e *GameEngine) doAppraise(player *Player, args []string) *CommandResult { } name := e.getItemNounName(itemDef) if matchesTarget(name, target, e.getAdjName(ii.Adj1)) { - if skip > 0 { skip--; continue } + if skip > 0 { + skip-- + continue + } displayName := e.formatItemName(itemDef, ii.Adj1, ii.Adj2, ii.Adj3) sellValue := ii.Val1 if sellValue <= 0 { sellValue = itemDef.Weight + 1 } sellValue = sellValue / 2 - if sellValue < 1 { sellValue = 1 } + if sellValue < 1 { + sellValue = 1 + } return &CommandResult{Messages: []string{ fmt.Sprintf("The merchant examines %s carefully.", displayName), fmt.Sprintf("\"I'd give you %s for that.\"", formatPrice(sellValue)), @@ -4376,7 +7716,10 @@ func (e *GameEngine) doDrink(ctx context.Context, player *Player, args []string) if !matchesTarget(name, target, e.getAdjName(ii.Adj1)) { continue } - if skip > 0 { skip--; continue } + if skip > 0 { + skip-- + continue + } displayName := e.formatItemName(itemDef, ii.Adj1, ii.Adj2, ii.Adj3) if itemDef.Type == "FOOD" { // EAT logic — redirect to doEat @@ -4409,70 +7752,318 @@ func (e *GameEngine) doDrink(ctx context.Context, player *Player, args []string) msgs = []string{fmt.Sprintf("You take a sip from %s. (%d sips remaining)", displayName, newVal)} } - msgs = append(msgs, sc.Messages...) + msgs = append(msgs, sc.Messages...) + + // Spell effect fires on FIRST sip only + if isFirstSip && spellNum != 0 { + if spellNum == 403 { + player.TelepathyActive = true + player.TelepathyExpiry = time.Now().Add(1 * time.Hour) + msgs = append(msgs, "You feel your mind open to the thoughts of others.") + } else { + msgs = append(msgs, fmt.Sprintf("[Spell #%d effect coming soon.]", spellNum)) + } + } + e.SavePlayer(ctx, player) + return &CommandResult{ + Messages: msgs, + RoomBroadcast: []string{fmt.Sprintf("%s drinks from %s.", player.FirstName, displayName)}, + PlayerState: player, + } + } + return &CommandResult{Messages: []string{"You don't have that."}} +} + +func (e *GameEngine) doLight( + ctx context.Context, + player *Player, + args []string, + lightOn bool, +) *CommandResult { + + if len(args) == 0 { + if lightOn { + return &CommandResult{Messages: []string{"Light what?"}} + } + return &CommandResult{Messages: []string{"Extinguish what?"}} + } + + target := strings.ToLower(strings.Join(args, " ")) + target, ordSkip := parseOrdinal(target) + + // + // Check carried items first + // + skip := ordSkip + + for i := range player.Inventory { + ii := &player.Inventory[i] + + itemDef := e.items[ii.Archetype] + if itemDef == nil { + continue + } + + if !containsFlag(itemDef.Flags, "LIGHTABLE") { + continue + } + + name := e.getItemNounName(itemDef) + if !matchesTarget(name, target, e.getAdjName(ii.Adj1)) { + continue + } + + if skip > 0 { + skip-- + continue + } + + if lightOn { + if ii.State == "LIT" { + displayName := e.formatItemName( + itemDef, + ii.Adj1, + ii.Adj2, + ii.Adj3, + ) + + return &CommandResult{ + Messages: []string{ + fmt.Sprintf("%s is already lit.", displayName), + }, + } + } + + // Get the name BEFORE applying the lit adjective. + originalName := e.formatItemName( + itemDef, + ii.Adj1, + ii.Adj2, + ii.Adj3, + ) + + // Apply the lit adjective. + if itemDef.Parameter1 > 0 { + ii.Val5 = ii.Adj1 + ii.Adj1 = itemDef.Parameter1 + } + + ii.State = "LIT" + + e.SavePlayer(ctx, player) + + return &CommandResult{ + Messages: []string{ + fmt.Sprintf("You light %s.", originalName), + }, + RoomBroadcast: []string{ + fmt.Sprintf("%s lights %s.", player.FirstName, originalName), + }, + } + } + + // Extinguish + if ii.State != "LIT" { + displayName := e.formatItemName( + itemDef, + ii.Adj1, + ii.Adj2, + ii.Adj3, + ) + + return &CommandResult{ + Messages: []string{ + fmt.Sprintf("%s is not lit.", displayName), + }, + } + } + + if itemDef.Parameter1 > 0 { + ii.Adj1 = ii.Val5 + ii.Val5 = 0 + } + + ii.State = "UNLIT" + + displayName := e.formatItemName( + itemDef, + ii.Adj1, + ii.Adj2, + ii.Adj3, + ) + + e.SavePlayer(ctx, player) + + return &CommandResult{ + Messages: []string{ + fmt.Sprintf("You extinguish %s.", displayName), + }, + RoomBroadcast: []string{ + fmt.Sprintf("%s extinguishes %s.", player.FirstName, displayName), + }, + } + } + + // + // Then check items in the room + // + room := e.rooms[player.RoomNumber] + if room != nil { + skip = ordSkip + + for i := range room.Items { + ri := &room.Items[i] + + itemDef := e.items[ri.Archetype] + if itemDef == nil { + continue + } + + if !containsFlag(itemDef.Flags, "LIGHTABLE") { + continue + } + + name := e.getItemNounName(itemDef) + if !matchesTarget(name, target, e.getAdjName(ri.Adj1)) { + continue + } + + if skip > 0 { + skip-- + continue + } + + if lightOn { + if ri.State == "LIT" { + displayName := e.formatItemName( + itemDef, + ri.Adj1, + ri.Adj2, + ri.Adj3, + ) + + return &CommandResult{ + Messages: []string{ + fmt.Sprintf("%s is already lit.", displayName), + }, + } + } + + if itemDef.Parameter1 > 0 { + ri.Val5 = ri.Adj1 + ri.Adj1 = itemDef.Parameter1 + } + + ri.State = "LIT" + + displayName := e.formatItemName( + itemDef, + ri.Adj1, + ri.Adj2, + ri.Adj3, + ) + + e.notifyRoomChange(RoomChange{ + RoomNumber: player.RoomNumber, + Type: "item_update", + Item: ri, + }) + + return &CommandResult{ + Messages: []string{ + fmt.Sprintf("You light %s.", displayName), + }, + RoomBroadcast: []string{ + fmt.Sprintf("%s lights %s.", player.FirstName, displayName), + }, + } + } + + // Extinguish + if ri.State != "LIT" { + displayName := e.formatItemName( + itemDef, + ri.Adj1, + ri.Adj2, + ri.Adj3, + ) + + return &CommandResult{ + Messages: []string{ + fmt.Sprintf("%s is not lit.", displayName), + }, + } + } - // Spell effect fires on FIRST sip only - if isFirstSip && spellNum != 0 { - if spellNum == 403 { - player.TelepathyActive = true - player.TelepathyExpiry = time.Now().Add(1 * time.Hour) - msgs = append(msgs, "You feel your mind open to the thoughts of others.") - } else { - msgs = append(msgs, fmt.Sprintf("[Spell #%d effect coming soon.]", spellNum)) + if itemDef.Parameter1 > 0 { + ri.Adj1 = ri.Val5 + ri.Val5 = 0 + } + + ri.State = "UNLIT" + + displayName := e.formatItemName( + itemDef, + ri.Adj1, + ri.Adj2, + ri.Adj3, + ) + + e.notifyRoomChange(RoomChange{ + RoomNumber: player.RoomNumber, + Type: "item_update", + Item: ri, + }) + + return &CommandResult{ + Messages: []string{ + fmt.Sprintf("You extinguish %s.", displayName), + }, + RoomBroadcast: []string{ + fmt.Sprintf("%s extinguishes %s.", player.FirstName, displayName), + }, } } - e.SavePlayer(ctx, player) + } + + if lightOn { return &CommandResult{ - Messages: msgs, - RoomBroadcast: []string{fmt.Sprintf("%s drinks from %s.", player.FirstName, displayName)}, - PlayerState: player, + Messages: []string{"You don't see anything here that you can light."}, } } - return &CommandResult{Messages: []string{"You don't have that."}} -} -func (e *GameEngine) doLight(ctx context.Context, player *Player, args []string, lightOn bool) *CommandResult { - if len(args) == 0 { - if lightOn { return &CommandResult{Messages: []string{"Light what?"}} } - return &CommandResult{Messages: []string{"Extinguish what?"}} - } - target := strings.ToLower(strings.Join(args, " ")) - target, ordSkip := parseOrdinal(target) - skip := ordSkip - for i, ii := range player.Inventory { - itemDef := e.items[ii.Archetype] - if itemDef == nil { continue } - if !containsFlag(itemDef.Flags, "LIGHTABLE") { continue } - name := e.getItemNounName(itemDef) - if !matchesTarget(name, target, e.getAdjName(ii.Adj1)) { continue } - if skip > 0 { skip--; continue } - displayName := e.formatItemName(itemDef, ii.Adj1, ii.Adj2, ii.Adj3) - if lightOn { - player.Inventory[i].State = "LIT" - e.SavePlayer(ctx, player) - return &CommandResult{Messages: []string{fmt.Sprintf("You light %s.", displayName)}} - } - player.Inventory[i].State = "UNLIT" - e.SavePlayer(ctx, player) - return &CommandResult{Messages: []string{fmt.Sprintf("You extinguish %s.", displayName)}} + return &CommandResult{ + Messages: []string{"You don't see anything here that you can extinguish."}, } - return &CommandResult{Messages: []string{"You don't have anything to light."}} } func (e *GameEngine) doFlip(ctx context.Context, player *Player, args []string) *CommandResult { - if len(args) == 0 { return &CommandResult{Messages: []string{"Flip what?"}} } + if len(args) == 0 { + return &CommandResult{Messages: []string{"Flip what?"}} + } target := strings.ToLower(strings.Join(args, " ")) target, ordSkip := parseOrdinal(target) skip := ordSkip room := e.rooms[player.RoomNumber] - if room == nil { return &CommandResult{Messages: []string{"You can't do that here."}} } + if room == nil { + return &CommandResult{Messages: []string{"You can't do that here."}} + } for i, ri := range room.Items { itemDef := e.items[ri.Archetype] - if itemDef == nil { continue } - if !containsFlag(itemDef.Flags, "FLIPABLE") { continue } + if itemDef == nil { + continue + } + if !containsFlag(itemDef.Flags, "FLIPABLE") { + continue + } name := e.getItemNounName(itemDef) - if !matchesTarget(name, target, e.getAdjName(ri.Adj1)) { continue } - if skip > 0 { skip--; continue } + if !matchesTarget(name, target, e.getAdjName(ri.Adj1)) { + continue + } + if skip > 0 { + skip-- + continue + } displayName := e.formatItemName(itemDef, ri.Adj1, ri.Adj2, ri.Adj3) if ri.State == "FLIPPED" { room.Items[i].State = "UNFLIPPED" @@ -4493,21 +8084,34 @@ func (e *GameEngine) doFlip(ctx context.Context, player *Player, args []string) func (e *GameEngine) doLatch(player *Player, args []string, latch bool) *CommandResult { if len(args) == 0 { - if latch { return &CommandResult{Messages: []string{"Latch what?"}} } + if latch { + return &CommandResult{Messages: []string{"Latch what?"}} + } return &CommandResult{Messages: []string{"Unlatch what?"}} } target := strings.ToLower(strings.Join(args, " ")) target, ordSkip := parseOrdinal(target) skip := ordSkip room := e.rooms[player.RoomNumber] - if room == nil { return &CommandResult{Messages: []string{"You can't do that here."}} } + if room == nil { + return &CommandResult{Messages: []string{"You can't do that here."}} + } for i, ri := range room.Items { itemDef := e.items[ri.Archetype] - if itemDef == nil { continue } - if !containsFlag(itemDef.Flags, "LATCHABLE") { continue } + if itemDef == nil { + continue + } + if !containsFlag(itemDef.Flags, "LATCHABLE") { + continue + } name := e.getItemNounName(itemDef) - if !matchesTarget(name, target, e.getAdjName(ri.Adj1)) { continue } - if skip > 0 { skip--; continue } + if !matchesTarget(name, target, e.getAdjName(ri.Adj1)) { + continue + } + if skip > 0 { + skip-- + continue + } displayName := e.formatItemName(itemDef, ri.Adj1, ri.Adj2, ri.Adj3) if latch { room.Items[i].State = "LATCHED" @@ -4569,149 +8173,509 @@ func (e *GameEngine) doUnlock(ctx context.Context, player *Player, args []string if len(args) == 0 { return &CommandResult{Messages: []string{"Unlock what?"}} } - raw := strings.ToLower(strings.Join(args, " ")) - target, keyName := parseWithClause(raw) + raw := strings.ToLower(strings.Join(args, " ")) + target, keyName := parseWithClause(raw) + target, ordSkip := parseOrdinal(target) + skip := ordSkip + room := e.rooms[player.RoomNumber] + if room == nil { + return &CommandResult{Messages: []string{"You can't do that here."}} + } + for i, ri := range room.Items { + itemDef := e.items[ri.Archetype] + if itemDef == nil { + continue + } + if !containsFlag(itemDef.Flags, "LOCKABLE") { + continue + } + name := e.getItemNounName(itemDef) + if !matchesTarget(name, target, e.getAdjName(ri.Adj1)) { + continue + } + if skip > 0 { + skip-- + continue + } + if ri.State != "LOCKED" { + return &CommandResult{Messages: []string{"It isn't locked."}} + } + // Find matching key + keyItem := e.findKey(player, ri.Val3, keyName) + if keyItem == nil { + return &CommandResult{Messages: []string{"You don't have the right key."}} + } + room.Items[i].State = "CLOSED" + e.notifyRoomChange(RoomChange{RoomNumber: player.RoomNumber, Type: "item_state", ItemRef: ri.Ref, NewState: "CLOSED"}) + displayName := e.formatItemName(itemDef, ri.Adj1, ri.Adj2, ri.Adj3) + return &CommandResult{Messages: []string{fmt.Sprintf("You unlock %s.", displayName)}} + } + return &CommandResult{Messages: []string{"You don't see anything to unlock here."}} +} + +// parseWithClause splits "target with key" into (target, key). If no "with", key is "". +func parseWithClause(s string) (string, string) { + idx := strings.Index(s, " with ") + if idx < 0 { + return s, "" + } + return strings.TrimSpace(s[:idx]), strings.TrimSpace(s[idx+6:]) +} + +// findKey searches the player's inventory for a KEY-type item whose Val3 matches lockVal3. +// If keyName is non-empty, the key must also match that name. +func (e *GameEngine) findKey(player *Player, lockVal3 int, keyName string) *InventoryItem { + allItems := make([]InventoryItem, 0, len(player.Inventory)) + allItems = append(allItems, player.Inventory...) + for i := range allItems { + ii := &allItems[i] + itemDef := e.items[ii.Archetype] + if itemDef == nil { + continue + } + if !strings.EqualFold(itemDef.Type, "KEY") { + continue + } + if ii.Val3 != lockVal3 { + continue + } + if keyName != "" { + name := e.getItemNounName(itemDef) + if !matchesTarget(name, keyName, e.getAdjName(ii.Adj1)) { + continue + } + } + return ii + } + return nil +} + +func (e *GameEngine) doRoomRecall(player *Player) *CommandResult { + room := e.rooms[player.RoomNumber] + if room == nil { + return &CommandResult{Messages: []string{"Nothing comes to mind."}} + } + sc := &ScriptContext{Player: player, Room: room, Engine: e} + for _, block := range room.Scripts { + if block.Type == "IFVERB" && len(block.Args) >= 2 { + if strings.EqualFold(block.Args[0], "RECALL") && block.Args[1] == "-1" { + sc.execBlock(block) + } + } + } + if len(sc.Messages) > 0 { + return &CommandResult{Messages: sc.Messages} + } + return &CommandResult{Messages: []string{"Nothing comes to mind about this place."}} +} + +func (e *GameEngine) doRoomScriptVerb(ctx context.Context, player *Player, verb string) *CommandResult { + + room := e.rooms[player.RoomNumber] + if room == nil { + return nil + } + + scriptItem := gameworld.RoomItem{Ref: -1} + scriptDef := &gameworld.ItemDef{} + + sc := e.RunVerbScripts( + player, + room, + verb, + &scriptItem, + scriptDef, + ) + + // No matching room script. + if len(sc.Messages) == 0 && + len(sc.RoomMsgs) == 0 && + len(sc.GMMsgs) == 0 && + !sc.Blocked && + sc.MoveTo == 0 && + sc.MoveGroupTo == 0 { + + return nil + } + + result := &CommandResult{ + Messages: append([]string{}, sc.Messages...), + RoomBroadcast: append([]string{}, sc.RoomMsgs...), + GMBroadcast: append([]string{}, sc.GMMsgs...), + } + + // The script requested MOVE . + if sc.MoveTo > 0 { + dest := e.rooms[sc.MoveTo] + + if dest != nil { + oldRoom := player.RoomNumber + + player.RoomNumber = sc.MoveTo + e.SavePlayer(ctx, player) + + lookResult := e.doLook(player) + + result.Messages = append( + result.Messages, + lookResult.Messages..., + ) + + result.RoomName = lookResult.RoomName + result.RoomDesc = lookResult.RoomDesc + result.Exits = lookResult.Exits + result.Items = lookResult.Items + result.OldRoom = oldRoom + + e.applyEntryScripts(ctx, player, dest, result) + } + } + + // The script requested MOVEGROUP . + if sc.MoveGroupTo > 0 { + e.moveGroupToRoom( + ctx, + player.RoomNumber, + sc.MoveGroupTo, + ) + } + + e.SavePlayer(ctx, player) + + return result +} + +func (e *GameEngine) doDeposit(ctx context.Context, player *Player, args []string) *CommandResult { + + room := e.rooms[player.RoomNumber] + if room == nil || !containsModifier(room.Modifiers, "BANK") { + return &CommandResult{Messages: []string{"There is no bank here."}} + } + + if len(args) == 0 { + return &CommandResult{Messages: []string{"Deposit what?"}} + } + + // If the first argument is a number, it's a money deposit. + if amount, err := strconv.Atoi(args[0]); err == nil { + return e.doDepositMoney(ctx, player, amount) + } + + // Otherwise, treat it as an item. + return e.doDepositItem(ctx, player, args) +} + +func (e *GameEngine) doDepositMoney(ctx context.Context, player *Player, amount int) *CommandResult { + room := e.rooms[player.RoomNumber] + if room == nil || !containsModifier(room.Modifiers, "BANK") { + return &CommandResult{Messages: []string{"There is no bank here."}} + } + if amount == 0 { + return &CommandResult{Messages: []string{"Deposit how much?"}} + } + + if amount <= 0 { + return &CommandResult{Messages: []string{"Invalid amount."}} + } + totalCopper := player.Gold*100 + player.Silver*10 + player.Copper + if totalCopper < amount { + return &CommandResult{Messages: []string{"You don't have that much money."}} + } + // Deduct from carried + remaining := amount + if player.Copper >= remaining { + player.Copper -= remaining + remaining = 0 + } else { + remaining -= player.Copper + player.Copper = 0 + } + if remaining > 0 { + sn := (remaining + 9) / 10 + if player.Silver >= sn { + player.Silver -= sn + player.Copper += sn*10 - remaining + remaining = 0 + } else { + remaining -= player.Silver * 10 + player.Silver = 0 + } + } + if remaining > 0 { + gn := (remaining + 99) / 100 + player.Gold -= gn + player.Copper += gn*100 - remaining + } + player.BankCopper += amount + e.SavePlayer(ctx, player) + return &CommandResult{Messages: []string{fmt.Sprintf("You deposit %s.", formatPrice(amount))}} +} + +func (e *GameEngine) doDepositItem(ctx context.Context, player *Player, args []string) *CommandResult { + + if len(args) == 0 { + return &CommandResult{ + Messages: []string{"Deposit what?"}, + } + } + + if len(player.BankInventory) >= MaxBankItems { + return &CommandResult{ + Messages: []string{ + fmt.Sprintf( + "Your safety deposit box is full. You may store up to %d items.", + MaxBankItems, + ), + }, + PlayerState: player, + } + } + + target := strings.ToLower(strings.Join(args, " ")) target, ordSkip := parseOrdinal(target) skip := ordSkip - room := e.rooms[player.RoomNumber] - if room == nil { - return &CommandResult{Messages: []string{"You can't do that here."}} - } - for i, ri := range room.Items { - itemDef := e.items[ri.Archetype] + + for i, ii := range player.Inventory { + itemDef := e.items[ii.Archetype] if itemDef == nil { continue } - if !containsFlag(itemDef.Flags, "LOCKABLE") { - continue - } + name := e.getItemNounName(itemDef) - if !matchesTarget(name, target, e.getAdjName(ri.Adj1)) { + + if !matchesTarget( + name, + target, + e.getAdjName(ii.Adj1), + ) { continue } + if skip > 0 { skip-- continue } - if ri.State != "LOCKED" { - return &CommandResult{Messages: []string{"It isn't locked."}} - } - // Find matching key - keyItem := e.findKey(player, ri.Val3, keyName) - if keyItem == nil { - return &CommandResult{Messages: []string{"You don't have the right key."}} + + // One gold crown = 100 copper. + const depositFee = 100 + + totalCopper := player.Gold*100 + + player.Silver*10 + + player.Copper + + if totalCopper < depositFee { + return &CommandResult{ + Messages: []string{ + "You need one gold crown to deposit an item.", + }, + } } - room.Items[i].State = "CLOSED" - e.notifyRoomChange(RoomChange{RoomNumber: player.RoomNumber, Type: "item_state", ItemRef: ri.Ref, NewState: "CLOSED"}) - displayName := e.formatItemName(itemDef, ri.Adj1, ri.Adj2, ri.Adj3) - return &CommandResult{Messages: []string{fmt.Sprintf("You unlock %s.", displayName)}} - } - return &CommandResult{Messages: []string{"You don't see anything to unlock here."}} -} -// parseWithClause splits "target with key" into (target, key). If no "with", key is "". -func parseWithClause(s string) (string, string) { - idx := strings.Index(s, " with ") - if idx < 0 { - return s, "" - } - return strings.TrimSpace(s[:idx]), strings.TrimSpace(s[idx+6:]) -} + // Deduct the 100-copper deposit fee. + remaining := depositFee -// findKey searches the player's inventory for a KEY-type item whose Val3 matches lockVal3. -// If keyName is non-empty, the key must also match that name. -func (e *GameEngine) findKey(player *Player, lockVal3 int, keyName string) *InventoryItem { - allItems := make([]InventoryItem, 0, len(player.Inventory)) - allItems = append(allItems, player.Inventory...) - for i := range allItems { - ii := &allItems[i] - itemDef := e.items[ii.Archetype] - if itemDef == nil { - continue + if player.Copper >= remaining { + player.Copper -= remaining + remaining = 0 + } else { + remaining -= player.Copper + player.Copper = 0 } - if !strings.EqualFold(itemDef.Type, "KEY") { - continue + + if remaining > 0 { + sn := (remaining + 9) / 10 + + if player.Silver >= sn { + player.Silver -= sn + player.Copper += sn*10 - remaining + remaining = 0 + } else { + remaining -= player.Silver * 10 + player.Silver = 0 + } } - if ii.Val3 != lockVal3 { - continue + + if remaining > 0 { + gn := (remaining + 99) / 100 + + player.Gold -= gn + player.Copper += gn*100 - remaining } - if keyName != "" { - name := e.getItemNounName(itemDef) - if !matchesTarget(name, keyName, e.getAdjName(ii.Adj1)) { - continue - } + + // Preserve the full inventory item, including contents. + player.BankInventory = append( + player.BankInventory, + ii, + ) + + // Remove it from carried inventory. + player.Inventory = append( + player.Inventory[:i], + player.Inventory[i+1:]..., + ) + + e.SavePlayer(ctx, player) + + fullName := e.formatItemName( + itemDef, + ii.Adj1, + ii.Adj2, + ii.Adj3, + ) + + return &CommandResult{ + Messages: []string{ + fmt.Sprintf( + "You hand the clerk a gold crown and place %s in your safety deposit box.", + fullName, + ), + }, + PlayerState: player, } - return ii } - return nil + + return &CommandResult{ + Messages: []string{"You aren't carrying that."}, + } } -func (e *GameEngine) doRoomRecall(player *Player) *CommandResult { +func (e *GameEngine) doWithdraw(ctx context.Context, player *Player, args []string) *CommandResult { + room := e.rooms[player.RoomNumber] - if room == nil { - return &CommandResult{Messages: []string{"Nothing comes to mind."}} + if room == nil || !containsModifier(room.Modifiers, "BANK") { + return &CommandResult{ + Messages: []string{"There is no bank here."}, + } } - sc := &ScriptContext{Player: player, Room: room, Engine: e} - for _, block := range room.Scripts { - if block.Type == "IFVERB" && len(block.Args) >= 2 { - if strings.EqualFold(block.Args[0], "RECALL") && block.Args[1] == "-1" { - sc.execBlock(block) - } + + if len(args) == 0 { + return &CommandResult{ + Messages: []string{"Withdraw what?"}, } } - if len(sc.Messages) > 0 { - return &CommandResult{Messages: sc.Messages} + + if amount, err := strconv.Atoi(args[0]); err == nil { + return e.doWithdrawMoney(ctx, player, amount) } - return &CommandResult{Messages: []string{"Nothing comes to mind about this place."}} + + return e.doWithdrawItem(ctx, player, args) } -func (e *GameEngine) doDeposit(ctx context.Context, player *Player, args []string) *CommandResult { +func (e *GameEngine) doWithdrawMoney(ctx context.Context, player *Player, amount int) *CommandResult { room := e.rooms[player.RoomNumber] if room == nil || !containsModifier(room.Modifiers, "BANK") { return &CommandResult{Messages: []string{"There is no bank here."}} } - if len(args) == 0 { return &CommandResult{Messages: []string{"Deposit how much?"}} } - amount := 0 - fmt.Sscanf(args[0], "%d", &amount) - if amount <= 0 { return &CommandResult{Messages: []string{"Invalid amount."}} } - totalCopper := player.Gold*100 + player.Silver*10 + player.Copper - if totalCopper < amount { - return &CommandResult{Messages: []string{"You don't have that much money."}} + if amount == 0 { + return &CommandResult{Messages: []string{"Withdraw how much?"}} } - // Deduct from carried - remaining := amount - if player.Copper >= remaining { player.Copper -= remaining; remaining = 0 } else { remaining -= player.Copper; player.Copper = 0 } - if remaining > 0 { sn := (remaining+9)/10; if player.Silver >= sn { player.Silver -= sn; player.Copper += sn*10-remaining; remaining = 0 } else { remaining -= player.Silver*10; player.Silver = 0 } } - if remaining > 0 { gn := (remaining+99)/100; player.Gold -= gn; player.Copper += gn*100-remaining } - player.BankCopper += amount - e.SavePlayer(ctx, player) - return &CommandResult{Messages: []string{fmt.Sprintf("You deposit %s.", formatPrice(amount))}} -} -func (e *GameEngine) doWithdraw(ctx context.Context, player *Player, args []string) *CommandResult { - room := e.rooms[player.RoomNumber] - if room == nil || !containsModifier(room.Modifiers, "BANK") { - return &CommandResult{Messages: []string{"There is no bank here."}} + if amount <= 0 { + return &CommandResult{Messages: []string{"Invalid amount."}} } - if len(args) == 0 { return &CommandResult{Messages: []string{"Withdraw how much?"}} } - amount := 0 - fmt.Sscanf(args[0], "%d", &amount) - if amount <= 0 { return &CommandResult{Messages: []string{"Invalid amount."}} } totalBank := player.BankGold*100 + player.BankSilver*10 + player.BankCopper if totalBank < amount { return &CommandResult{Messages: []string{"You don't have that much in the bank."}} } remaining := amount - if player.BankCopper >= remaining { player.BankCopper -= remaining; remaining = 0 } else { remaining -= player.BankCopper; player.BankCopper = 0 } - if remaining > 0 { sn := (remaining+9)/10; if player.BankSilver >= sn { player.BankSilver -= sn; player.BankCopper += sn*10-remaining; remaining = 0 } else { remaining -= player.BankSilver*10; player.BankSilver = 0 } } - if remaining > 0 { gn := (remaining+99)/100; player.BankGold -= gn; player.BankCopper += gn*100-remaining } + if player.BankCopper >= remaining { + player.BankCopper -= remaining + remaining = 0 + } else { + remaining -= player.BankCopper + player.BankCopper = 0 + } + if remaining > 0 { + sn := (remaining + 9) / 10 + if player.BankSilver >= sn { + player.BankSilver -= sn + player.BankCopper += sn*10 - remaining + remaining = 0 + } else { + remaining -= player.BankSilver * 10 + player.BankSilver = 0 + } + } + if remaining > 0 { + gn := (remaining + 99) / 100 + player.BankGold -= gn + player.BankCopper += gn*100 - remaining + } player.Copper += amount e.SavePlayer(ctx, player) return &CommandResult{Messages: []string{fmt.Sprintf("You withdraw %s.", formatPrice(amount))}} } +func (e *GameEngine) doWithdrawItem(ctx context.Context, player *Player, args []string) *CommandResult { + + if len(args) == 0 { + return &CommandResult{ + Messages: []string{"Withdraw what?"}, + } + } + + target := strings.ToLower(strings.Join(args, " ")) + target, ordSkip := parseOrdinal(target) + skip := ordSkip + + for i, ii := range player.BankInventory { + itemDef := e.items[ii.Archetype] + if itemDef == nil { + continue + } + + name := e.getItemNounName(itemDef) + + if !matchesTarget( + name, + target, + e.getAdjName(ii.Adj1), + ) { + continue + } + + if skip > 0 { + skip-- + continue + } + + // Move the complete item back into carried inventory. + // Contents come with it automatically because they're + // stored inside the inventory item. + player.Inventory = append( + player.Inventory, + ii, + ) + + // Remove it from the safety deposit box. + player.BankInventory = append( + player.BankInventory[:i], + player.BankInventory[i+1:]..., + ) + + e.SavePlayer(ctx, player) + + return &CommandResult{ + Messages: []string{ + "You retrieve the item from your safety deposit box.", + }, + PlayerState: player, + } + } + + return &CommandResult{ + Messages: []string{ + "That item is not in your safety deposit box.", + }, + } +} + func containsModifier(mods []string, mod string) bool { - for _, m := range mods { if m == mod { return true } } + for _, m := range mods { + if m == mod { + return true + } + } return false } @@ -4793,8 +8757,10 @@ func (e *GameEngine) doHide(ctx context.Context, player *Player) *CommandResult } // Stealth skill check: base 25% + stealth*5 + AGI/10 stealthSkill := player.Skills[33] - hideChance := 25 + stealthSkill*5 + player.Agility/10 - if hideChance > 95 { hideChance = 95 } + hideChance := 25 + stealthSkill*5 + player.EffectiveStat(StatAgility)/10 + if hideChance > 95 { + hideChance = 95 + } if rand.Intn(100) >= hideChance { return &CommandResult{ Messages: []string{"You fail to find a suitable hiding place."}, @@ -4829,8 +8795,10 @@ func (e *GameEngine) doSneak(ctx context.Context, player *Player, args []string) } // Stealth check to stay hidden while moving stealthSkill := player.Skills[33] - sneakChance := 30 + stealthSkill*5 + player.Agility/10 - if sneakChance > 90 { sneakChance = 90 } + sneakChance := 30 + stealthSkill*5 + player.EffectiveStat(StatAgility)/10 + if sneakChance > 90 { + sneakChance = 90 + } result := e.doMove(ctx, player, dir) if rand.Intn(100) >= sneakChance { player.Hidden = false @@ -4874,10 +8842,10 @@ func (e *GameEngine) doMark(ctx context.Context, player *Player, args []string) name = r.Name } if player.IsGM { - msgs = append(msgs, fmt.Sprintf(" Mark %d: %s (%d)", i, name, roomNum)) - } else { - msgs = append(msgs, fmt.Sprintf(" Mark %d: %s", i, name)) - } + msgs = append(msgs, fmt.Sprintf(" Mark %d: %s (%d)", i, name, roomNum)) + } else { + msgs = append(msgs, fmt.Sprintf(" Mark %d: %s", i, name)) + } } } return &CommandResult{Messages: msgs} @@ -4922,13 +8890,39 @@ func (e *GameEngine) doBalance(player *Player) *CommandResult { if room == nil || !containsModifier(room.Modifiers, "BANK") { return &CommandResult{Messages: []string{"You need to be at a bank to check your balance."}} } + msgs := []string{"=== Bank Balance ==="} + total := player.BankGold*100 + player.BankSilver*10 + player.BankCopper if total == 0 { msgs = append(msgs, "Your account is empty.") } else { msgs = append(msgs, fmt.Sprintf("Balance: %s", formatPrice(total))) } + + // Safety deposit box + msgs = append(msgs, "", "Safety Deposit Box:") + + if len(player.BankInventory) == 0 { + msgs = append(msgs, " Empty.") + } else { + for _, ii := range player.BankInventory { + itemDef := e.items[ii.Archetype] + if itemDef == nil { + continue + } + + fullName := e.formatItemName( + itemDef, + ii.Adj1, + ii.Adj2, + ii.Adj3, + ) + + msgs = append(msgs, " "+fullName) + } + } + return &CommandResult{Messages: msgs} } @@ -4999,7 +8993,10 @@ func (e *GameEngine) doRead(player *Player, args []string) *CommandResult { } name := e.getItemNounName(itemDef) if matchesTarget(name, target, e.getAdjName(ri.Adj1)) { - if skip > 0 { skip--; continue } + if skip > 0 { + skip-- + continue + } return e.readRoomItem(room, itemDef, &ri) } } @@ -5008,7 +9005,9 @@ func (e *GameEngine) doRead(player *Player, args []string) *CommandResult { allReadItems := make([]InventoryItem, 0, len(player.Inventory)+len(player.Worn)+1) allReadItems = append(allReadItems, player.Inventory...) allReadItems = append(allReadItems, player.Worn...) - if player.Wielded != nil { allReadItems = append(allReadItems, *player.Wielded) } + if player.Wielded != nil { + allReadItems = append(allReadItems, *player.Wielded) + } for _, ii := range allReadItems { itemDef := e.items[ii.Archetype] if itemDef == nil { @@ -5016,7 +9015,10 @@ func (e *GameEngine) doRead(player *Player, args []string) *CommandResult { } name := e.getItemNounName(itemDef) if matchesTarget(name, target, e.getAdjName(ii.Adj1)) || matchesTarget(name, target, e.getAdjName(ii.Adj3)) { - if skip > 0 { skip--; continue } + if skip > 0 { + skip-- + continue + } return &CommandResult{Messages: []string{"There is nothing written on it."}} } } @@ -5083,19 +9085,16 @@ func (e *GameEngine) doHelp() *CommandResult { // CreateNewPlayer generates a fresh character and persists it to MongoDB. func (e *GameEngine) CreateNewPlayer(ctx context.Context, firstName, lastName string, race, gender int, accountID ...string) *Player { - ranges := RaceStatRanges[race] - rollStat := func(idx int) int { - r := ranges[idx] - return r[0] + rand.Intn(r[1]-r[0]+1) - } - str := rollStat(0) - agi := rollStat(1) - qui := rollStat(2) - con := rollStat(3) - per := rollStat(4) - wil := rollStat(5) - emp := rollStat(6) + stats := rollStatsForRace(race) + + str := stats.Strength + agi := stats.Agility + qui := stats.Quickness + con := stats.Constitution + per := stats.Perception + wil := stats.Willpower + emp := stats.Empathy bodyPts := 20 + con/2 fatigue := 20 + (con+str)/3 @@ -5105,14 +9104,14 @@ func (e *GameEngine) CreateNewPlayer(ctx context.Context, firstName, lastName st // Race-based height/weight ranges: [minHeight, maxHeight, minWeight, maxWeight] // Heights in inches, weights in lbs. Based on original GM manual. heightWeightRanges := map[int][4]int{ - 1: {62, 76, 120, 220}, // Human - 2: {66, 80, 100, 170}, // Aelfen (tall, slender) - 3: {48, 58, 130, 200}, // Highlander (short, rugged) - 4: {64, 74, 130, 200}, // Wolfling - 5: {62, 74, 150, 230}, // Murg (burly) - 6: {68, 82, 150, 250}, // Drakin (large) - 7: {60, 74, 150, 250}, // Mechanoid - 8: {58, 72, 80, 130}, // Ephemeral (wispy) + 1: {62, 76, 120, 220}, // Human + 2: {66, 80, 100, 170}, // Aelfen (tall, slender) + 3: {48, 58, 130, 200}, // Highlander (short, rugged) + 4: {64, 74, 130, 200}, // Wolfling + 5: {62, 74, 150, 230}, // Murg (burly) + 6: {68, 82, 150, 250}, // Drakin (large) + 7: {60, 74, 150, 250}, // Mechanoid + 8: {58, 72, 80, 130}, // Ephemeral (wispy) } hw := heightWeightRanges[race] if hw == [4]int{} { @@ -5124,49 +9123,53 @@ func (e *GameEngine) CreateNewPlayer(ctx context.Context, firstName, lastName st if gender == 1 { height -= 2 + rand.Intn(3) weight -= 10 + rand.Intn(20) - if height < hw[0]-4 { height = hw[0] - 4 } - if weight < hw[2]-20 { weight = hw[2] - 20 } + if height < hw[0]-4 { + height = hw[0] - 4 + } + if weight < hw[2]-20 { + weight = hw[2] - 20 + } } now := time.Now() player := &Player{ - FirstName: firstName, - LastName: lastName, - Race: race, - Gender: gender, - Level: 1, - BuildPoints: 30, // 30 starting build points for initial skills - Strength: str, - Agility: agi, - Quickness: qui, - Constitution: con, - Perception: per, - Willpower: wil, - Empathy: emp, - BodyPoints: bodyPts, - MaxBodyPoints: bodyPts, - Fatigue: fatigue, - MaxFatigue: fatigue, - Mana: mana, - MaxMana: mana, - Psi: psi, - MaxPsi: psi, - Height: height, - HeightTrue: height, - Weight: weight, - WeightTrue: weight, - RoomNumber: 201, // Start at City Gate (tutorial room 3950 requires script execution) - Position: 0, - Skills: make(map[int]int), - IntNums: make(map[int]int), - Gold: 5, - Silver: 10, - Copper: 50, - PromptMode: true, - SuppressLogon: true, // login/logout messages off by default for new characters - SuppressLogoff: true, - CreatedAt: now, - UpdatedAt: now, + FirstName: firstName, + LastName: lastName, + Race: race, + Gender: gender, + Level: 1, + BuildPoints: 30, // 30 starting build points for initial skills + Strength: str, + Agility: agi, + Quickness: qui, + Constitution: con, + Perception: per, + Willpower: wil, + Empathy: emp, + BodyPoints: bodyPts, + MaxBodyPoints: bodyPts, + Fatigue: fatigue, + MaxFatigue: fatigue, + Mana: mana, + MaxMana: mana, + Psi: psi, + MaxPsi: psi, + Height: height, + HeightTrue: height, + Weight: weight, + WeightTrue: weight, + RoomNumber: 201, // Start at City Gate (tutorial room 3950 requires script execution) + Position: 0, + Skills: make(map[int]int), + IntNums: make(map[int]int), + Gold: 5, + Silver: 10, + Copper: 50, + PromptMode: true, + SuppressLogon: true, // login/logout messages off by default for new characters + SuppressLogoff: true, + CreatedAt: now, + UpdatedAt: now, } if race == RaceEphemeral { player.TelepathyActive = true @@ -5427,7 +9430,12 @@ func (e *GameEngine) doSet(ctx context.Context, player *Player, args []string) * } lines := []string{ "Current Settings:", - fmt.Sprintf(" Full: %s", func() string { if player.BriefMode { return "OFF" }; return "ON" }()), + fmt.Sprintf(" Full: %s", func() string { + if player.BriefMode { + return "OFF" + } + return "ON" + }()), fmt.Sprintf(" Brief: %s", briefMode), fmt.Sprintf(" Prompt: %s", promptMode), fmt.Sprintf(" Logon messages: %s", onOff(player.SuppressLogon)), @@ -5522,13 +9530,19 @@ func (e *GameEngine) SavePlayer(ctx context.Context, player *Player) { func (e *GameEngine) formatItemNameNoArticle(def *gameworld.ItemDef, adj1, adj2, adj3 int) string { var parts []string if adj1 > 0 { - if name, ok := e.adjectives[adj1]; ok { parts = append(parts, name) } + if name, ok := e.adjectives[adj1]; ok { + parts = append(parts, name) + } } if adj2 > 0 { - if name, ok := e.adjectives[adj2]; ok { parts = append(parts, name) } + if name, ok := e.adjectives[adj2]; ok { + parts = append(parts, name) + } } if adj3 > 0 { - if name, ok := e.adjectives[adj3]; ok { parts = append(parts, name) } + if name, ok := e.adjectives[adj3]; ok { + parts = append(parts, name) + } } parts = append(parts, e.getItemNounName(def)) return strings.Join(parts, " ") @@ -5591,21 +9605,95 @@ func (e *GameEngine) adjByName(name string) int { return id } } - return 0 -} + return 0 +} + +func (e *GameEngine) getAdjName(adjID int) string { + if adjID > 0 { + if name, ok := e.adjectives[adjID]; ok { + return name + } + } + return "" +} + +func (e *GameEngine) lookInContainer(player *Player, def *gameworld.ItemDef, ii *InventoryItem) *CommandResult { + + name := e.formatItemName( + def, + ii.Adj1, + ii.Adj2, + ii.Adj3, + ) + + if ii.State != "OPEN" && ii.State != "" { + return &CommandResult{ + Messages: []string{ + fmt.Sprintf("You'll need to open %s first.", name), + }, + } + } + + msgs := []string{ + fmt.Sprintf("You look in %s.", name), + } + + found := false + usedVolume := 0 + + for _, child := range ii.Contents { + childDef := e.items[child.Archetype] + if childDef == nil { + continue + } + + if childDef.Type == "MONEY" { + switch childDef.Parameter1 { + case MoneyGold: + msgs = append(msgs, "You see some gold coins.") + case MoneySilver: + msgs = append(msgs, "You see some silver coins.") + case MoneyCopper: + msgs = append(msgs, "You see some copper coins.") + default: + msgs = append(msgs, "You see some coins.") + } + + found = true + continue + } + + childName := e.formatItemName( + childDef, + child.Adj1, + child.Adj2, + child.Adj3, + ) + + usedVolume += childDef.Volume + + msgs = append( + msgs, + fmt.Sprintf("You see %s.", childName), + ) + + found = true + } -func (e *GameEngine) getAdjName(adjID int) string { - if adjID > 0 { - if name, ok := e.adjectives[adjID]; ok { - return name - } + if def.Interior > 0 { + msgs = append( + msgs, + fmt.Sprintf("Capacity: %d/%d", usedVolume, def.Interior), + ) } - return "" -} -func (e *GameEngine) lookInContainer(player *Player, def *gameworld.ItemDef, ii *InventoryItem) *CommandResult { - name := e.formatItemName(def, ii.Adj1, ii.Adj2, ii.Adj3) - return &CommandResult{Messages: []string{fmt.Sprintf("You look in %s. It is empty.", name)}} + if !found { + msgs = append(msgs, "It is empty.") + } + + return &CommandResult{ + Messages: msgs, + } } func (e *GameEngine) examineRoomItem(player *Player, room *gameworld.Room, def *gameworld.ItemDef, ri *gameworld.RoomItem) *CommandResult { @@ -5869,8 +9957,8 @@ func (e *GameEngine) GenerateAPIKey(ctx context.Context, firstName, accountID st filter := bson.M{"firstName": firstName, "accountId": accountID, "deletedAt": bson.M{"$exists": false}} update := bson.M{"$set": bson.M{ "apiKeyHash": hashStr, - "apiKeyPrefix": prefix, - "botGMAllowed": allowGM, + "apiKeyPrefix": prefix, + "botGMAllowed": allowGM, }} result, err := coll.UpdateOne(ctx, filter, update) if err != nil { @@ -5913,7 +10001,9 @@ func (e *GameEngine) ValidateAPIKey(ctx context.Context, key string) (*Player, e } func isSelfOr(isSelf bool, selfText, otherText string) string { - if isSelf { return selfText } + if isSelf { + return selfText + } return otherText } @@ -5994,7 +10084,7 @@ func xpUntilNextBuildPoint(player *Player) int { return 0 } // Walk XP through levels to find leftover in current level - bp := 20 + bp := 30 lvl := 1 xpRemaining := player.Experience @@ -6021,6 +10111,7 @@ func xpUntilNextBuildPoint(player *Player) int { } // playerLoadWeight calculates total weight of carried items. +/* func playerLoadWeight(player *Player, items map[int]*gameworld.ItemDef) int { total := 0 for _, ii := range player.Inventory { @@ -6030,40 +10121,82 @@ func playerLoadWeight(player *Player, items map[int]*gameworld.ItemDef) int { } return total } +*/ + +func inventoryItemWeight(item InventoryItem, items map[int]*gameworld.ItemDef) int { + total := 0 + + if def := items[item.Archetype]; def != nil { + total += def.Weight + } + + for _, child := range item.Contents { + total += inventoryItemWeight(child, items) + } + + return total +} + +func playerLoadWeight(player *Player, items map[int]*gameworld.ItemDef) int { + + total := 0 + + for _, item := range player.Inventory { + total += inventoryItemWeight(item, items) + } + + return total +} // doSkin handles the SKIN command — skin a dead monster for components. func (e *GameEngine) doSkin(ctx context.Context, player *Player, args []string) *CommandResult { if len(args) == 0 { return &CommandResult{Messages: []string{"Skin what?"}} } - target := strings.ToLower(strings.Join(args, " ")) - // Find a dead monster in the room + rawTarget := strings.ToLower(strings.Join(args, " ")) + target, ordSkip := parseOrdinal(rawTarget) + if e.monsterMgr == nil { return &CommandResult{Messages: []string{"You don't see that here."}} } + matchCount := 0 monsters := e.monsterMgr.AllMonstersInRoom(player.RoomNumber) + for _, inst := range monsters { if inst.Alive { - continue // can only skin dead monsters + continue } + def := e.monsters[inst.DefNumber] if def == nil { continue } + name := strings.ToLower(FormatMonsterName(def, e.monAdjs)) noun := strings.ToLower(def.Name) - if !strings.HasPrefix(name, target) && !strings.HasPrefix(noun, target) { + + if !strings.HasPrefix(name, target) && + !strings.HasPrefix(noun, target) { + continue + } + + matchCount++ + if matchCount <= ordSkip { continue } if def.Discorporate { - return &CommandResult{Messages: []string{"There is nothing left to skin."}} + return &CommandResult{ + Messages: []string{"There is nothing left to skin."}, + } } if inst.Skinned { - return &CommandResult{Messages: []string{"This corpse has already been skinned."}} + return &CommandResult{ + Messages: []string{"This corpse has already been skinned."}, + } } // Check for skin items @@ -6108,7 +10241,7 @@ func (e *GameEngine) doSkin(ctx context.Context, player *Player, args []string) skinMsgs = append(skinMsgs, fmt.Sprintf("You skin %s %s but find nothing useful.", articleFor(displayName, def.Unique), displayName)) } - inst.Skinned = true + e.monsterMgr.MarkSkinned(inst.ID) e.SavePlayer(ctx, player) return &CommandResult{ Messages: skinMsgs, @@ -6428,3 +10561,416 @@ func (e *GameEngine) doTurnPage(ctx context.Context, player *Player, args []stri return nil // fall through to item interaction } + +func rollStatsForRace(race int) PendingStats { + ranges := RaceStatRanges[race] + + rollStat := func(idx int) int { + r := ranges[idx] + return r[0] + rand.Intn(r[1]-r[0]+1) + } + + return PendingStats{ + Strength: rollStat(0), + Agility: rollStat(1), + Quickness: rollStat(2), + Constitution: rollStat(3), + Perception: rollStat(4), + Willpower: rollStat(5), + Empathy: rollStat(6), + } +} + +func (e *GameEngine) doReports(ctx context.Context, player *Player, args []string) *CommandResult { + + if !player.IsGM { + return &CommandResult{ + Messages: []string{"You are not authorized to view reports."}, + PlayerState: player, + } + } + + if e.db == nil { + return &CommandResult{ + Messages: []string{"Database is unavailable."}, + PlayerState: player, + } + } + + limit := int64(25) + + // Optional: REPORTS 50 + if len(args) > 0 { + if n, err := strconv.Atoi(args[0]); err == nil && n > 0 { + if n > 100 { + n = 100 + } + limit = int64(n) + } + } + + coll := e.db.Collection("game_logs") + + opts := options.Find(). + SetSort(bson.D{{Key: "timestamp", Value: -1}}). + SetLimit(limit) + + cursor, err := coll.Find( + ctx, + bson.M{"event": "report"}, + opts, + ) + + if err != nil { + return &CommandResult{ + Messages: []string{ + fmt.Sprintf("Unable to retrieve reports: %v", err), + }, + PlayerState: player, + } + } + defer cursor.Close(ctx) + + type reportLog struct { + ID bson.ObjectID `bson:"_id"` + Timestamp time.Time `bson:"timestamp"` + Player string `bson:"player"` + Details string `bson:"details"` + RoomNum int `bson:"roomNum"` + } + + var reports []reportLog + + if err := cursor.All(ctx, &reports); err != nil { + return &CommandResult{ + Messages: []string{ + fmt.Sprintf("Unable to read reports: %v", err), + }, + PlayerState: player, + } + } + + if len(reports) == 0 { + return &CommandResult{ + Messages: []string{"No player reports found."}, + PlayerState: player, + } + } + + msgs := []string{ + fmt.Sprintf("=== Player Reports (%d) ===", len(reports)), + } + + for _, r := range reports { + msgs = append( + msgs, + fmt.Sprintf( + "%s | %s | Room %d | ID: %s", + r.Timestamp.Local().Format("2006-01-02 15:04"), + r.Player, + r.RoomNum, + r.ID.Hex(), + ), + fmt.Sprintf(" %s", r.Details), + ) + } + + return &CommandResult{ + Messages: msgs, + PlayerState: player, + } +} + +func (e *GameEngine) doReportComplete(ctx context.Context, player *Player, args []string) *CommandResult { + + if !player.IsGM { + return &CommandResult{ + Messages: []string{"You are not authorized to complete reports."}, + PlayerState: player, + } + } + + if len(args) == 0 { + return &CommandResult{ + Messages: []string{"Usage: REPORTCOMPLETE "}, + PlayerState: player, + } + } + + if e.db == nil { + return &CommandResult{ + Messages: []string{"Database is unavailable."}, + PlayerState: player, + } + } + + id, err := bson.ObjectIDFromHex(args[0]) + if err != nil { + return &CommandResult{ + Messages: []string{"Invalid report ID."}, + PlayerState: player, + } + } + + result, err := e.db.Collection("game_logs").UpdateOne( + ctx, + bson.M{ + "_id": id, + "event": "report", + }, + bson.M{ + "$set": bson.M{ + "event": "reportcomplete", + }, + }, + ) + + if err != nil { + return &CommandResult{ + Messages: []string{ + fmt.Sprintf("Unable to complete report: %v", err), + }, + PlayerState: player, + } + } + + if result.MatchedCount == 0 { + return &CommandResult{ + Messages: []string{"Report not found."}, + PlayerState: player, + } + } + + return &CommandResult{ + Messages: []string{"Report marked complete."}, + PlayerState: player, + } +} + +func (e *GameEngine) RefreshEncumbrance(player *Player) { + // Remove previous encumbrance effects. + active := player.ActiveStatEffects[:0] + + for _, effect := range player.ActiveStatEffects { + if effect.Source != EffectSourceEncumbrance { + active = append(active, effect) + } + } + + player.ActiveStatEffects = active + + load := playerLoadWeight(player, e.items) + strength := player.EffectiveStat(StatStrength) + + penalty := 0 + + switch { + case load > strength*5/2: + penalty = -50 + + case load > strength*2: + penalty = -25 + + case load > strength+strength/2: + penalty = -10 + } + + if penalty == 0 { + return + } + + player.ActiveStatEffects = append( + player.ActiveStatEffects, + StatEffect{ + Source: EffectSourceEncumbrance, + Stat: StatAgility, + Modifier: penalty, + Permanent: true, + }, + StatEffect{ + Source: EffectSourceEncumbrance, + Stat: StatQuickness, + Modifier: penalty, + Permanent: true, // Encumbrance effects do not expire on their own + }, + ) +} + +func (e *GameEngine) roomHasLightSource(roomNum int) bool { + if e.sessions == nil { + return false + } + + now := time.Now() + + for _, p := range e.sessions.OnlinePlayers() { + if p.RoomNumber != roomNum { + continue + } + + // Magical Light spell + for _, effect := range p.ActiveStatEffects { + if effect.Stat == LightBuff && + (effect.Permanent || effect.ExpiresAt.After(now)) { + return true + } + } + + // Physical light sources carried by players + for _, item := range p.Inventory { + def := e.items[item.Archetype] + if def == nil { + continue + } + + if containsFlag(def.Flags, "LIGHTABLE") && + item.State == "LIT" { + return true + } + } + } + + // Light sources lying in the room. + room := e.rooms[roomNum] + if room != nil { + for _, item := range room.Items { + def := e.items[item.Archetype] + if def == nil { + continue + } + + if containsFlag(def.Flags, "LIGHTABLE") && + item.State == "LIT" { + return true + } + } + } + + return false +} + +func playerCanSeeInDark(player *Player) bool { + switch player.Race { + case RaceMurg, RaceHighlander: + return true + default: + return false + } +} + +func (e *GameEngine) canPlayerSee(player *Player, room *gameworld.Room) bool { + if room == nil { + return false + } + return e.roomVisibility(player, room) != VisibilityDark +} + +func (e *GameEngine) roomVisibility(player *Player, room *gameworld.Room) VisibilityLevel { + if room == nil { + return VisibilityDark + } + + // Racial dark vision + if playerCanSeeInDark(player) { + return VisibilityClear + } + + // Personal night vision PARTIAL_DARKNESS + if player != nil && player.HasStatEffect(NightVisionBuff) { + return VisibilityClear + } + + // Any active light source in the room illuminates it for everyone. + if e.roomHasLightSource(room.Number) { + return VisibilityClear + } + + switch room.Lighting { + case "FIXED_LIGHT": + return VisibilityClear + + case "DARKNESS": + return VisibilityDark + + case "DAY_LIGHT", "OBSCURED_DAY_LIGHT": + if IsDay() { + return VisibilityClear + } + return VisibilityDark + + case "PARTIAL_DARKNESS": + return VisibilityPartial + + case "LIMITED_VISION": + return VisibilityLimited + + default: + return VisibilityClear + } +} + +func (p *Player) HasStatEffect(stat StatID) bool { + now := time.Now() + + for _, effect := range p.ActiveStatEffects { + if effect.Stat != stat { + continue + } + + if effect.Permanent || effect.ExpiresAt.After(now) { + return true + } + } + + return false +} + +func (e *GameEngine) visibilityCombatModifier(player *Player, RoomNumber int) int { + + room := e.rooms[RoomNumber] + + switch e.roomVisibility(player, room) { + case VisibilityDark: + return -40 + + case VisibilityPartial: + return -20 + + case VisibilityLimited: + return -10 + + default: + return 0 + } +} + +func (e *GameEngine) visibilityStatus(player *Player, room *gameworld.Room) string { + if room == nil { + return "" + } + + mod := e.visibilityCombatModifier(player, room.Number) + + switch e.roomVisibility(player, room) { + case VisibilityDark: + return fmt.Sprintf( + "Visibility: Darkness (%d attack, %d defense)", + mod, mod, + ) + + case VisibilityPartial: + return fmt.Sprintf( + "Visibility: Partial darkness (%d attack, %d defense)", + mod, mod, + ) + + case VisibilityLimited: + return fmt.Sprintf( + "Visibility: Limited vision (%d attack, %d defense)", + mod, mod, + ) + + default: + return "" + } +} diff --git a/engine/internal/engine/monsters.go b/engine/internal/engine/monsters.go index 38e4cdc..513ef2f 100644 --- a/engine/internal/engine/monsters.go +++ b/engine/internal/engine/monsters.go @@ -7,23 +7,26 @@ import ( "sync" "time" + "go.mongodb.org/mongo-driver/v2/bson" + "github.com/jonradoff/lofp/internal/gameworld" ) // MonsterInstance represents a spawned monster in the world. type MonsterInstance struct { - ID int `json:"id"` - DefNumber int `json:"defNumber"` - RoomNumber int `json:"roomNumber"` - Alive bool `json:"alive"` - Sedated bool `json:"sedated"` - Stunned bool `json:"-"` // stunned: skip next combat tick, easier to hit - Skinned bool `json:"-"` // already skinned - DefenseBonus int `json:"-"` // from active psi defenses - CurrentHP int `json:"currentHP"` - Target string `json:"-"` - Searched bool `json:"-"` // already searched for loot - DeathTime time.Time `json:"-"` // when it died (for corpse decay) + ID int `json:"id"` + DefNumber int `json:"defNumber"` + RoomNumber int `json:"roomNumber"` + Alive bool `json:"alive"` + Sedated bool `json:"sedated"` + Stunned bool `json:"-"` // stunned: skip next combat tick, easier to hit + Skinned bool `json:"-"` // already skinned + DefenseBonus int `json:"-"` // from active psi defenses + CurrentHP int `json:"currentHP"` + Target string `json:"-"` + Searched bool `json:"-"` // already searched for loot + DeathTime time.Time `json:"-"` // when it died (for corpse decay) + DamageByPlayer map[bson.ObjectID]int `json:"-"` // tracks damage dealt by players for loot distribution } // monsterManager handles monster spawning and tracking. @@ -31,7 +34,7 @@ type monsterManager struct { mu sync.RWMutex instances []MonsterInstance nextID int - monstersByRoom map[int][]int // roomNumber -> slice of instance indices + monstersByRoom map[int][]int // roomNumber -> slice of instance indices roomLastPlayer map[int]time.Time // roomNumber -> last time a player was present } @@ -233,6 +236,26 @@ func (mm *monsterManager) AllMonstersInRoom(roomNum int) []MonsterInstance { return result } +func (mm *monsterManager) MarkSkinned(instanceID int) bool { + mm.mu.Lock() + defer mm.mu.Unlock() + + for i := range mm.instances { + if mm.instances[i].ID != instanceID { + continue + } + + if mm.instances[i].Skinned { + return false + } + + mm.instances[i].Skinned = true + return true + } + + return false +} + // moveMonster moves a monster instance to a new room. Must be called under lock. func (mm *monsterManager) moveMonster(idx int, newRoom int) { oldRoom := mm.instances[idx].RoomNumber diff --git a/engine/internal/engine/player.go b/engine/internal/engine/player.go index 8ce0b3e..62bb039 100644 --- a/engine/internal/engine/player.go +++ b/engine/internal/engine/player.go @@ -1,6 +1,7 @@ package engine import ( + "fmt" "time" "go.mongodb.org/mongo-driver/v2/bson" @@ -36,22 +37,74 @@ var RaceStatRanges = map[int][7][2]int{ RaceWolfling: {{30, 100}, {40, 110}, {40, 110}, {30, 100}, {40, 110}, {30, 100}, {30, 100}}, } +type StatID int + +const ( + StatStrength StatID = iota + StatAgility + StatQuickness + StatConstitution + StatPerception + StatWillpower + StatEmpathy + HasteBuff + SlowDebuff + StatBodyPoint + StatFatigue + StatMana + StatPsi + DefensiveBuff + LightBuff + NightVisionBuff +) + +const ( + PoisonMinor = 5 + PoisonModerate = 15 + PoisonMajor = 30 + PoisonNerveGas = 40 + PoisonLethal = 60 +) + +type EffectSource int + +const ( + EffectSourceSpell EffectSource = iota + EffectSourcePotion + EffectSourcePoison + EffectSourceDisease + EffectSourceItem + EffectSourceGM + EffectSourceScript + EffectSourceEncumbrance +) + // Gender constants const ( GenderMale = 0 GenderFemale = 1 ) +type PendingStats struct { + Strength int + Agility int + Quickness int + Constitution int + Perception int + Willpower int + Empathy int +} + // Player represents a player's current state. type Player struct { - ID bson.ObjectID `bson:"_id,omitempty" json:"id"` - AccountID string `bson:"accountId,omitempty" json:"accountId,omitempty"` - FirstName string `bson:"firstName" json:"firstName"` - LastName string `bson:"lastName" json:"lastName"` - Race int `bson:"race" json:"race"` - Gender int `bson:"gender" json:"gender"` - Level int `bson:"level" json:"level"` - Experience int `bson:"experience" json:"experience"` + ID bson.ObjectID `bson:"_id,omitempty" json:"id"` + AccountID string `bson:"accountId,omitempty" json:"accountId,omitempty"` + FirstName string `bson:"firstName" json:"firstName"` + LastName string `bson:"lastName" json:"lastName"` + Race int `bson:"race" json:"race"` + Gender int `bson:"gender" json:"gender"` + Level int `bson:"level" json:"level"` + Experience int `bson:"experience" json:"experience"` // Stats Strength int `bson:"strength" json:"strength"` @@ -74,33 +127,33 @@ type Player struct { // Position RoomNumber int `bson:"roomNumber" json:"roomNumber"` - Position int `bson:"position" json:"position"` // 0=standing, 1=sitting, 2=laying, 3=kneeling, 4=flying - Hidden bool `bson:"hidden" json:"hidden"` // stealth: revealed by movement, emotes, attacks - Invisible bool `bson:"invisible" json:"invisible"` // spell effect: not revealed by movement, only by attacks or dispel + Position int `bson:"position" json:"position"` // 0=standing, 1=sitting, 2=laying, 3=kneeling, 4=flying + Hidden bool `bson:"hidden" json:"hidden"` // stealth: revealed by movement, emotes, attacks + Invisible bool `bson:"invisible" json:"invisible"` // spell effect: not revealed by movement, only by attacks or dispel Dead bool `bson:"dead" json:"dead"` // Physical attributes - Height int `bson:"height,omitempty" json:"height,omitempty"` // inches + Height int `bson:"height,omitempty" json:"height,omitempty"` // inches HeightTrue int `bson:"heightTrue,omitempty" json:"heightTrue,omitempty"` - Weight int `bson:"weight,omitempty" json:"weight,omitempty"` // pounds (base, not inventory) + Weight int `bson:"weight,omitempty" json:"weight,omitempty"` // pounds (base, not inventory) WeightTrue int `bson:"weightTrue,omitempty" json:"weightTrue,omitempty"` Age int `bson:"age,omitempty" json:"age,omitempty"` AgeTrue int `bson:"ageTrue,omitempty" json:"ageTrue,omitempty"` // Status conditions - Bleeding bool `bson:"bleeding" json:"bleeding"` - Stunned bool `bson:"stunned" json:"stunned"` - Diseased bool `bson:"diseased" json:"diseased"` - Poisoned bool `bson:"poisoned" json:"poisoned"` - Joined bool `bson:"joined" json:"joined"` - Unconscious bool `bson:"unconscious" json:"unconscious"` - Immobilized bool `bson:"immobilized" json:"immobilized"` - Sleeping bool `bson:"sleeping,omitempty" json:"sleeping,omitempty"` - Submitting bool `bson:"submitting,omitempty" json:"submitting,omitempty"` - Undead bool `bson:"undead,omitempty" json:"undead,omitempty"` - WolfForm bool `bson:"wolfForm,omitempty" json:"wolfForm,omitempty"` - SlimeForm bool `bson:"slimeForm,omitempty" json:"slimeForm,omitempty"` - Disguised bool `bson:"disguised,omitempty" json:"disguised,omitempty"` + Bleeding bool `bson:"bleeding" json:"bleeding"` + Stunned bool `bson:"stunned" json:"stunned"` + Diseased bool `bson:"diseased" json:"diseased"` + Poisoned bool `bson:"poisoned" json:"poisoned"` + Joined bool `bson:"joined" json:"joined"` + Unconscious bool `bson:"unconscious" json:"unconscious"` + Immobilized bool `bson:"immobilized" json:"immobilized"` + Sleeping bool `bson:"sleeping,omitempty" json:"sleeping,omitempty"` + Submitting bool `bson:"submitting,omitempty" json:"submitting,omitempty"` + Undead bool `bson:"undead,omitempty" json:"undead,omitempty"` + WolfForm bool `bson:"wolfForm,omitempty" json:"wolfForm,omitempty"` + SlimeForm bool `bson:"slimeForm,omitempty" json:"slimeForm,omitempty"` + Disguised bool `bson:"disguised,omitempty" json:"disguised,omitempty"` RoundTime int `bson:"roundTime" json:"roundTime"` RoundTimeExpiry time.Time `bson:"-" json:"-"` // transient: when roundtime ends CanFly bool `bson:"canFly" json:"canFly"` @@ -108,20 +161,23 @@ type Player struct { PreparedSpell int `bson:"preparedSpell,omitempty" json:"preparedSpell,omitempty"` // Combat - Stance int `bson:"-" json:"-"` // StanceNormal..StanceBerserk - CombatTarget *CombatTarget `bson:"-" json:"-"` // current combat target - DefenseBonus int `bson:"-" json:"-"` // from spells/psi - PreparedPsi int `bson:"-" json:"-"` // prepared psi discipline ID - ActivePsi map[int]bool `bson:"-" json:"-"` // currently maintained psi disciplines - BackstabNext bool `bson:"-" json:"-"` // next attack is a backstab - TelepathyActive bool `bson:"telepathyActive,omitempty" json:"telepathyActive,omitempty"` - TelepathyExpiry time.Time `bson:"telepathyExpiry,omitempty" json:"telepathyExpiry,omitempty"` - Emotional bool `bson:"emotional,omitempty" json:"emotional,omitempty"` + Stance int `bson:"-" json:"-"` // StanceNormal..StanceBerserk + CombatTarget *CombatTarget `bson:"-" json:"-"` // current combat target + DefenseBonus int `bson:"-" json:"-"` // from spells/psi + PreparedPsi int `bson:"-" json:"-"` // prepared psi discipline ID + ActivePsi map[int]bool `bson:"-" json:"-"` // currently maintained psi disciplines + BackstabNext bool `bson:"-" json:"-"` // next attack is a backstab + TelepathyActive bool `bson:"telepathyActive,omitempty" json:"telepathyActive,omitempty"` + TelepathyExpiry time.Time `bson:"telepathyExpiry,omitempty" json:"telepathyExpiry,omitempty"` + Emotional bool `bson:"emotional,omitempty" json:"emotional,omitempty"` + + // Temporary spell/stat modifiers + ActiveStatEffects []StatEffect `bson:"activeStatEffects,omitempty" json:"activeStatEffects,omitempty"` // Crafting state (transient) - CraftingItem string `bson:"-" json:"-"` // what they're making (e.g., "greatsword") + CraftingItem string `bson:"-" json:"-"` // what they're making (e.g., "greatsword") CraftingMetal string `bson:"-" json:"-"` // what material (e.g., "copper") - CraftingStep int `bson:"-" json:"-"` // 0=not crafting, 1=planned, 2=heated, 3=hammered, 4=quenched, 5=buffed, 6=done + CraftingStep int `bson:"-" json:"-"` // 0=not crafting, 1=planned, 2=heated, 3=hammered, 4=quenched, 5=buffed, 6=done // Teaching: skill being taught to others (transient) Teaching int `bson:"-" json:"-"` // skill number being taught (0 = not teaching) @@ -141,6 +197,7 @@ type Player struct { // Inventory Inventory []InventoryItem `bson:"inventory" json:"inventory"` Wielded *InventoryItem `bson:"wielded,omitempty" json:"wielded,omitempty"` + Offhand *InventoryItem `bson:"offhand,omitempty" json:"offhand,omitempty"` Worn []InventoryItem `bson:"worn" json:"worn"` // Currency (carried) @@ -153,6 +210,9 @@ type Player struct { BankSilver int `bson:"bankSilver,omitempty" json:"bankSilver,omitempty"` BankCopper int `bson:"bankCopper,omitempty" json:"bankCopper,omitempty"` + //Bank inventory (items stored in the bank) + BankInventory []InventoryItem `bson:"bankInventory,omitempty" json:"bankInventory,omitempty"` + // Organization / Guild Organization int `bson:"organization,omitempty" json:"organization,omitempty"` // ORG OrgRank int `bson:"orgRank,omitempty" json:"orgRank,omitempty"` // ORGRANK @@ -161,7 +221,7 @@ type Player struct { BuildPoints int `bson:"buildPoints,omitempty" json:"buildPoints,omitempty"` // Skills - Skills map[int]int `bson:"skills" json:"skills"` // skill# -> level + Skills map[int]int `bson:"skills" json:"skills"` // skill# -> level KnownSpells map[int]bool `bson:"knownSpells,omitempty" json:"knownSpells,omitempty"` // spell# -> known // Internal variables (INTNUM0-99, flags, etc.) @@ -173,18 +233,20 @@ type Player struct { Flag3 int `bson:"-" json:"-"` Flag4 int `bson:"-" json:"-"` + PendingStatReroll *PendingStats `bson:"-" json:"-"` + // Appearance / Description - DescLine1 string `bson:"descLine1,omitempty" json:"descLine1,omitempty"` // custom description lines (visible on EXAMINE) - DescLine2 string `bson:"descLine2,omitempty" json:"descLine2,omitempty"` - DescLine3 string `bson:"descLine3,omitempty" json:"descLine3,omitempty"` - EntryEcho string `bson:"entryEcho,omitempty" json:"entryEcho,omitempty"` // custom room entry text (replaces "X arrives.") - ExitEcho string `bson:"exitEcho,omitempty" json:"exitEcho,omitempty"` // custom room exit text (replaces "X goes north.") + DescLine1 string `bson:"descLine1,omitempty" json:"descLine1,omitempty"` // custom description lines (visible on EXAMINE) + DescLine2 string `bson:"descLine2,omitempty" json:"descLine2,omitempty"` + DescLine3 string `bson:"descLine3,omitempty" json:"descLine3,omitempty"` + EntryEcho string `bson:"entryEcho,omitempty" json:"entryEcho,omitempty"` // custom room entry text (replaces "X arrives.") + ExitEcho string `bson:"exitEcho,omitempty" json:"exitEcho,omitempty"` // custom room exit text (replaces "X goes north.") // Bot / API Key - APIKeyHash string `bson:"apiKeyHash,omitempty" json:"-"` // bcrypt hash of API key (never sent to client) - APIKeyPrefix string `bson:"apiKeyPrefix,omitempty" json:"apiKeyPrefix,omitempty"` // first 8 chars for display - BotGMAllowed bool `bson:"botGMAllowed,omitempty" json:"botGMAllowed,omitempty"` // whether bot can use GM commands - IsBot bool `bson:"-" json:"-"` // transient: connected via API key + APIKeyHash string `bson:"apiKeyHash,omitempty" json:"-"` // bcrypt hash of API key (never sent to client) + APIKeyPrefix string `bson:"apiKeyPrefix,omitempty" json:"apiKeyPrefix,omitempty"` // first 8 chars for display + BotGMAllowed bool `bson:"botGMAllowed,omitempty" json:"botGMAllowed,omitempty"` // whether bot can use GM commands + IsBot bool `bson:"-" json:"-"` // transient: connected via API key // Settings (persistent toggles via SET command) // Note: SuppressLogon/Logoff default to true for new characters (set in CreateNewPlayer). @@ -201,11 +263,11 @@ type Player struct { PromptMode bool `bson:"promptMode" json:"promptMode"` SpeechAdverb string `bson:"speechAdverb,omitempty" json:"speechAdverb,omitempty"` // e.g. "gently" IsGM bool `bson:"isGM" json:"isGM"` - GMTrace bool `bson:"-" json:"-"` // @trace: show script debug output - GMHat bool `bson:"gmHat,omitempty" json:"gmHat,omitempty"` // visible as GM on WHO list - GMHidden bool `bson:"gmHidden,omitempty" json:"gmHidden,omitempty"` // hidden from WHO list - GMInvis bool `bson:"gmInvis,omitempty" json:"gmInvis,omitempty"` // invisible to players - GMEditTarget string `bson:"-" json:"-"` // @edpl target name for subsequent @set commands + GMTrace bool `bson:"-" json:"-"` // @trace: show script debug output + GMHat bool `bson:"gmHat,omitempty" json:"gmHat,omitempty"` // visible as GM on WHO list + GMHidden bool `bson:"gmHidden,omitempty" json:"gmHidden,omitempty"` // hidden from WHO list + GMInvis bool `bson:"gmInvis,omitempty" json:"gmInvis,omitempty"` // invisible to players + GMEditTarget string `bson:"-" json:"-"` // @edpl target name for subsequent @set commands // Player title (e.g., "the Baroness") — shown on LOOK/EXAMINE Title string `bson:"title,omitempty" json:"title,omitempty"` @@ -217,17 +279,27 @@ type Player struct { // InventoryItem is an instance of an item held by a player. type InventoryItem struct { - Archetype int `bson:"archetype" json:"archetype"` - Adj1 int `bson:"adj1,omitempty" json:"adj1,omitempty"` - Adj2 int `bson:"adj2,omitempty" json:"adj2,omitempty"` - Adj3 int `bson:"adj3,omitempty" json:"adj3,omitempty"` - Val1 int `bson:"val1,omitempty" json:"val1,omitempty"` - Val2 int `bson:"val2,omitempty" json:"val2,omitempty"` - Val3 int `bson:"val3,omitempty" json:"val3,omitempty"` - Val4 int `bson:"val4,omitempty" json:"val4,omitempty"` - Val5 int `bson:"val5,omitempty" json:"val5,omitempty"` - State string `bson:"state,omitempty" json:"state,omitempty"` - WornSlot string `bson:"wornSlot,omitempty" json:"wornSlot,omitempty"` + Archetype int `bson:"archetype" json:"archetype"` + Adj1 int `bson:"adj1,omitempty" json:"adj1,omitempty"` + Adj2 int `bson:"adj2,omitempty" json:"adj2,omitempty"` + Adj3 int `bson:"adj3,omitempty" json:"adj3,omitempty"` + Val1 int `bson:"val1,omitempty" json:"val1,omitempty"` + Val2 int `bson:"val2,omitempty" json:"val2,omitempty"` + Val3 int `bson:"val3,omitempty" json:"val3,omitempty"` + Val4 int `bson:"val4,omitempty" json:"val4,omitempty"` + Val5 int `bson:"val5,omitempty" json:"val5,omitempty"` + State string `bson:"state,omitempty" json:"state,omitempty"` + WornSlot string `bson:"wornSlot,omitempty" json:"wornSlot,omitempty"` + Contents []InventoryItem `bson:"contents,omitempty" json:"contents,omitempty"` +} + +type StatEffect struct { + EffectID int `bson:"effectId" json:"effectId"` + Source EffectSource `bson:"source" json:"source"` + Stat StatID `bson:"stat" json:"stat"` + Modifier int `bson:"modifier" json:"modifier"` + ExpiresAt time.Time `bson:"expiresAt,omitempty" json:"expiresAt,omitempty"` + Permanent bool `bson:"permanent,omitempty" json:"permanent,omitempty"` } // FullName returns the player's display name. @@ -346,3 +418,103 @@ func RaceNameByID(race int) string { func (p *Player) IsFlying() bool { return p.Race == RaceDrakin || p.CanFly } + +func (p *Player) EffectiveStat(stat StatID) int { + now := time.Now() + + var value int + + switch stat { + case StatStrength: + value = p.Strength + case StatAgility: + value = p.Agility + case StatQuickness: + value = p.Quickness + case StatConstitution: + value = p.Constitution + case StatPerception: + value = p.Perception + case StatWillpower: + value = p.Willpower + case StatEmpathy: + value = p.Empathy + default: + return 0 + } + + for _, effect := range p.ActiveStatEffects { + if effect.Stat != stat { + continue + } + + if effect.Permanent || effect.ExpiresAt.After(now) { + value += effect.Modifier + } + } + + return value +} + +func formatEffectiveStat(player *Player, stat StatID, base int) string { + effective := player.EffectiveStat(stat) + + if effective != base { + return fmt.Sprintf("%d*", effective) + } + + return fmt.Sprintf("%d", effective) +} + +func (p *Player) ApplyStatEffect( + effectID int, + source EffectSource, + stat StatID, + modifier int, + duration time.Duration, +) { + expiresAt := time.Now().Add(duration) + + for i := range p.ActiveStatEffects { + effect := &p.ActiveStatEffects[i] + + if effect.EffectID == effectID && effect.Source == source { + effect.Stat = stat + effect.Modifier = modifier + effect.ExpiresAt = expiresAt + return + } + } + + p.ActiveStatEffects = append(p.ActiveStatEffects, StatEffect{ + EffectID: effectID, + Source: source, + Stat: stat, + Modifier: modifier, + ExpiresAt: expiresAt, + }) + +} + +func (p *Player) HasItem(archetype int, adj int) bool { + for _, ii := range p.Inventory { + if ii.Archetype == archetype && (adj < 0 || ii.Adj1 == adj) { + return true + } + } + + for _, ii := range p.Worn { + if ii.Archetype == archetype && (adj < 0 || ii.Adj1 == adj) { + return true + } + } + + if p.Wielded != nil { + if p.Wielded.Archetype == archetype && + (adj < 0 || p.Wielded.Adj1 == adj) { + return true + } + } + + return false +} diff --git a/engine/internal/engine/psionics.go b/engine/internal/engine/psionics.go index 58f19e4..65b13e5 100644 --- a/engine/internal/engine/psionics.go +++ b/engine/internal/engine/psionics.go @@ -312,7 +312,7 @@ func (e *GameEngine) doProjectPsi(ctx context.Context, player *Player, args []st } else { schoolSkill = player.Skills[27] } - castChance := 50 + (psiSkill+schoolSkill)*3 + player.Willpower/5 - disc.Level*2 + castChance := 50 + (psiSkill+schoolSkill)*3 + player.EffectiveStat(StatWillpower)/5 - disc.Level*2 if castChance < 15 { castChance = 15 } @@ -453,7 +453,7 @@ func (e *GameEngine) projectDamage(player *Player, disc *PsiDiscipline, args []s } } - killed := e.damageMonster(inst.ID, dmg) + killed := e.damageMonster(player, inst.ID, dmg) if killed { deathText := def.TextOverrides["TEXD"] deathMsg := fmt.Sprintf("A %s collapses, dead!", name) @@ -556,7 +556,7 @@ func (e *GameEngine) projectManipulateLock(player *Player, args []string) *Comma } // Skill check: psi skill + willpower vs lock difficulty psiSkill := player.Skills[26] + player.Skills[28] - chance := 40 + psiSkill*3 + player.Willpower/5 + chance := 40 + psiSkill*3 + player.EffectiveStat(StatWillpower)/5 if player.IsGM { chance = 100 } diff --git a/engine/internal/engine/scripts.go b/engine/internal/engine/scripts.go index 2d03df1..d4fa3b8 100644 --- a/engine/internal/engine/scripts.go +++ b/engine/internal/engine/scripts.go @@ -5,23 +5,24 @@ import ( "math/rand" "strconv" "strings" + "time" "github.com/jonradoff/lofp/internal/gameworld" ) // ScriptContext holds state for script execution within a single trigger. type ScriptContext struct { - Player *Player - Room *gameworld.Room - Engine *GameEngine - Messages []string // ECHO PLAYER messages to send to the player - RoomMsgs []string // ECHO ALL / ECHO OTHERS messages for the room - GMMsgs []string // GMMSG messages for gamemasters - Blocked bool // CLEARVERB: block the triggering action - MoveTo int // MOVE: destination room (0 = no move) - MoveGroupTo int // MOVEGROUP: move all players in room to destination - - StrVars map[int]string // %0-%9 from STRCVT + Player *Player + Room *gameworld.Room + Engine *GameEngine + Messages []string // ECHO PLAYER messages to send to the player + RoomMsgs []string // ECHO ALL / ECHO OTHERS messages for the room + GMMsgs []string // GMMSG messages for gamemasters + Blocked bool // CLEARVERB: block the triggering action + MoveTo int // MOVE: destination room (0 = no move) + MoveGroupTo int // MOVEGROUP: move all players in room to destination + + StrVars map[int]string // %0-%9 from STRCVT OrigRoom *gameworld.Room // saved room for AFFECT // Item interaction context (set when running IFPREVERB/IFVERB on a room item) @@ -68,6 +69,91 @@ func (e *GameEngine) RunSayScripts(player *Player, room *gameworld.Room, text st // RunPreverbScripts executes IFPREVERB blocks for a specific verb and item ref. // Returns the script context. Check sc.Blocked to see if the action should be cancelled. +func (e *GameEngine) RunPreverbScripts( + player *Player, + room *gameworld.Room, + verb string, + ri *gameworld.RoomItem, + def *gameworld.ItemDef, + ri2 ...*gameworld.RoomItem, +) *ScriptContext { + + sc := &ScriptContext{ + Player: player, + Room: room, + Engine: e, + ItemRef: ri, // PRIMARY ITEM -- e.g. the dust + ItemDef: def, + } + + verb = strings.ToUpper(verb) + + // ------------------------------------------------------------ + // IFPREVERB -- primary item + // ------------------------------------------------------------ + + refStr := fmt.Sprintf("%d", ri.Ref) + + for _, block := range room.Scripts { + if block.Type == "IFPREVERB" && len(block.Args) >= 2 { + if strings.ToUpper(block.Args[0]) == verb && + block.Args[1] == refStr { + + sc.execBlock(block) + } + } + } + + for _, block := range def.Scripts { + if block.Type == "IFPREVERB" && len(block.Args) >= 1 { + if strings.ToUpper(block.Args[0]) == verb { + if len(block.Args) < 2 || block.Args[1] == "-1" { + sc.execBlock(block) + } + } + } + } + + // ------------------------------------------------------------ + // IFPREVERB2 -- second item + // ------------------------------------------------------------ + + if len(ri2) > 0 && ri2[0] != nil { + + secondItem := ri2[0] + secondRefStr := fmt.Sprintf("%d", secondItem.Ref) + + // Room-level IFPREVERB2 + for _, block := range room.Scripts { + if block.Type == "IFPREVERB2" && len(block.Args) >= 2 { + if strings.ToUpper(block.Args[0]) == verb && + block.Args[1] == secondRefStr { + + sc.execBlock(block) + } + } + } + + // Item-level IFPREVERB2 + secondDef := e.items[secondItem.Archetype] + + if secondDef != nil { + for _, block := range secondDef.Scripts { + if block.Type == "IFPREVERB2" && len(block.Args) >= 1 { + if strings.ToUpper(block.Args[0]) == verb { + if len(block.Args) < 2 || block.Args[1] == "-1" { + sc.execBlock(block) + } + } + } + } + } + } + + return sc +} + +/* func (e *GameEngine) RunPreverbScripts(player *Player, room *gameworld.Room, verb string, ri *gameworld.RoomItem, def *gameworld.ItemDef) *ScriptContext { sc := &ScriptContext{ Player: player, @@ -99,9 +185,29 @@ func (e *GameEngine) RunPreverbScripts(player *Player, room *gameworld.Room, ver } } } + // Check room-level IFPREVERB2 scripts + for _, block := range room.Scripts { + if block.Type == "IFPREVERB2" && len(block.Args) >= 2 { + if strings.ToUpper(block.Args[0]) == verb && block.Args[1] == refStr { + sc.execBlock(block) + } + } + } + + // Check item-level IFPREVERB2 scripts + for _, block := range def.Scripts { + if block.Type == "IFPREVERB2" && len(block.Args) >= 1 { + if strings.ToUpper(block.Args[0]) == verb { + if len(block.Args) < 2 || block.Args[1] == "-1" { + sc.execBlock(block) + } + } + } + } return sc } +*/ // RunVerbScripts executes IFVERB blocks for a specific verb and item. // RunItemScripts runs all root-level conditional blocks on an item definition @@ -171,7 +277,7 @@ func (sc *ScriptContext) execBlock(block gameworld.ScriptBlock) { case "IFENTRY": sc.execChildren(block) - case "IFPREVERB", "IFVERB": + case "IFPREVERB", "IFVERB", "IFPREVERB2": sc.execChildren(block) case "IFVAR": @@ -256,6 +362,8 @@ func (sc *ScriptContext) execChildren(block gameworld.ScriptBlock) { // execAction executes a single script action. func (sc *ScriptContext) execAction(action gameworld.ScriptAction) { switch action.Command { + case "SPELL": + sc.doSpell(action.Args) case "ECHO": sc.doEcho(action.Args) case "EQUAL": @@ -280,6 +388,8 @@ func (sc *ScriptContext) execAction(action gameworld.ScriptAction) { sc.doRandom(action.Args) case "DAMAGEPLR": sc.doDamagePlr(action.Args) + case "HEALPLR": + sc.doHealPlr(action.Args) case "STRCVT": sc.doStrCvt(action.Args) case "STRCPY": @@ -359,7 +469,7 @@ func (sc *ScriptContext) doEcho(args []string) { switch target { case "PLAYER": sc.Messages = append(sc.Messages, text) - case "ALL": + case "ALL", "GROUP": sc.Messages = append(sc.Messages, text) sc.RoomMsgs = append(sc.RoomMsgs, text) case "OTHERS": @@ -475,6 +585,7 @@ func (sc *ScriptContext) doMove(args []string) { dest := sc.resolveNumericArg(args[0]) if dest > 0 { sc.MoveTo = dest + // e.movePlayerToRoom(ctx, player, sc.MoveTo, result) } } @@ -713,10 +824,14 @@ func (sc *ScriptContext) getVar(name string) int { return 0 } switch idx { - case 1: return sc.Player.Flag1 - case 2: return sc.Player.Flag2 - case 3: return sc.Player.Flag3 - case 4: return sc.Player.Flag4 + case 1: + return sc.Player.Flag1 + case 2: + return sc.Player.Flag2 + case 3: + return sc.Player.Flag3 + case 4: + return sc.Player.Flag4 } return 0 } @@ -739,23 +854,46 @@ func (sc *ScriptContext) getVar(name string) int { case "RAC": return sc.Player.Race case "MISTFORM": - if sc.Player.Race == 8 { return 1 } + if sc.Player.Race == 8 { + return 1 + } return 0 // Player stats - case "STR", "STRT": + // case "STR" returns the base strength, while "STRT" returns the effective strength after modifiers. + case "STR": return sc.Player.Strength - case "AGI", "AGIT": + case "STRT": + return sc.Player.EffectiveStat(StatStrength) + + case "AGI": return sc.Player.Agility - case "CON", "CONT": + case "AGIT": + return sc.Player.EffectiveStat(StatAgility) + + case "CON": return sc.Player.Constitution - case "QUI", "QUIT": + case "CONT": + return sc.Player.EffectiveStat(StatConstitution) + + case "QUI": return sc.Player.Quickness - case "WIL", "WILT": + case "QUIT": + return sc.Player.EffectiveStat(StatQuickness) + + case "WIL": return sc.Player.Willpower - case "PER", "PERT": + case "WILT": + return sc.Player.EffectiveStat(StatWillpower) + + case "PER": return sc.Player.Perception - case "EMP", "EMPT": + case "PERT": + return sc.Player.EffectiveStat(StatPerception) + + case "EMP": return sc.Player.Empathy + case "EMPT": + return sc.Player.EffectiveStat(StatEmpathy) // Player resources case "BODYPOINTS": return sc.Player.BodyPoints @@ -775,25 +913,39 @@ func (sc *ScriptContext) getVar(name string) int { return sc.Player.MaxPsi // Player state case "DEAD": - if sc.Player.Dead { return 1 } + if sc.Player.Dead { + return 1 + } return 0 case "FLYING": - if sc.Player.Position == 4 { return 1 } + if sc.Player.Position == 4 { + return 1 + } return 0 case "KNEELING": - if sc.Player.Position == 3 { return 1 } + if sc.Player.Position == 3 { + return 1 + } return 0 case "LAYING": - if sc.Player.Position == 2 { return 1 } + if sc.Player.Position == 2 { + return 1 + } return 0 case "SITTING": - if sc.Player.Position == 1 { return 1 } + if sc.Player.Position == 1 { + return 1 + } return 0 case "STANDING": - if sc.Player.Position == 0 { return 1 } + if sc.Player.Position == 0 { + return 1 + } return 0 case "HIDDEN": - if sc.Player.Hidden { return 1 } + if sc.Player.Hidden { + return 1 + } return 0 // Organization case "ORG": @@ -806,22 +958,30 @@ func (sc *ScriptContext) getVar(name string) int { return sc.Player.BuildPoints // Wielded case "WIELDED": - if sc.Player.Wielded != nil { return 1 } + if sc.Player.Wielded != nil { + return 1 + } return 0 case "ARCHNUM": - if sc.Player.Wielded != nil { return sc.Player.Wielded.Archetype } + if sc.Player.Wielded != nil { + return sc.Player.Wielded.Archetype + } return 0 // Room info case "RNUM": return sc.Player.RoomNumber case "OUTDOOR": - if sc.Room != nil && isOutdoorTerrain(sc.Room.Terrain) { return 1 } + if sc.Room != nil && isOutdoorTerrain(sc.Room.Terrain) { + return 1 + } return 0 case "PLRSINROOM": if sc.Engine.sessions != nil { count := 0 for _, p := range sc.Engine.sessions.OnlinePlayers() { - if p.RoomNumber == sc.Player.RoomNumber { count++ } + if p.RoomNumber == sc.Player.RoomNumber { + count++ + } } return count } @@ -835,10 +995,14 @@ func (sc *ScriptContext) getVar(name string) int { case "TIM": return GameHour() case "DAY": - if IsDay() { return 1 } + if IsDay() { + return 1 + } return 0 case "NIGHT": - if IsNight() { return 1 } + if IsNight() { + return 1 + } return 0 case "DATE": return GameDay() @@ -870,25 +1034,39 @@ func (sc *ScriptContext) getVar(name string) int { return sc.Player.AgeTrue // Form states case "WOLFFORM": - if sc.Player.WolfForm { return 1 } + if sc.Player.WolfForm { + return 1 + } return 0 case "SLIMEFORM": - if sc.Player.SlimeForm { return 1 } + if sc.Player.SlimeForm { + return 1 + } return 0 case "OTHERFORM": - if sc.Player.WolfForm || sc.Player.SlimeForm || (sc.Player.Race == 8 && sc.Player.Hidden) { return 1 } + if sc.Player.WolfForm || sc.Player.SlimeForm || (sc.Player.Race == 8 && sc.Player.Hidden) { + return 1 + } return 0 case "UNDEAD": - if sc.Player.Undead { return 1 } + if sc.Player.Undead { + return 1 + } return 0 case "DISGUISED": - if sc.Player.Disguised { return 1 } + if sc.Player.Disguised { + return 1 + } return 0 case "SLEEPING": - if sc.Player.Sleeping { return 1 } + if sc.Player.Sleeping { + return 1 + } return 0 case "SUBMITTING": - if sc.Player.Submitting { return 1 } + if sc.Player.Submitting { + return 1 + } return 0 case "ROUNDTIME": return sc.Player.RoundTime @@ -909,7 +1087,9 @@ func (sc *ScriptContext) getVar(name string) int { } return 0 case "ASTRAL": - if sc.Room != nil && sc.Room.Terrain == "ASTRAL" { return 1 } + if sc.Room != nil && sc.Room.Terrain == "ASTRAL" { + return 1 + } return 0 case "TERRAIN": if sc.Room != nil { @@ -940,7 +1120,9 @@ func (sc *ScriptContext) getVar(name string) int { case "WEALTH2", "WEALTH3", "WEALTH4", "WEALTH5", "WEALTH6", "WEALTH7", "WEALTH8", "WEALTH9": return 0 // TODO: multi-currency per region case "OBJWEIGHT": - if sc.ItemDef != nil { return sc.ItemDef.Weight } + if sc.ItemDef != nil { + return sc.ItemDef.Weight + } return 0 case "PLAYERNUM": return 0 // TODO: unique player number @@ -1017,10 +1199,14 @@ func (sc *ScriptContext) setVar(name string, val int) { if strings.HasPrefix(name, "FLAG") { idx, _ := strconv.Atoi(name[4:]) switch idx { - case 1: sc.Player.Flag1 = val - case 2: sc.Player.Flag2 = val - case 3: sc.Player.Flag3 = val - case 4: sc.Player.Flag4 = val + case 1: + sc.Player.Flag1 = val + case 2: + sc.Player.Flag2 = val + case 3: + sc.Player.Flag3 = val + case 4: + sc.Player.Flag4 = val } return } @@ -1068,10 +1254,9 @@ func (sc *ScriptContext) setVar(name string, val int) { } } - // resolveNumericArg resolves a script argument that can be a literal number // or a variable reference like ITEMVAL2. -func (sc *ScriptContext) resolveNumericArg(arg string) int { +/*func (sc *ScriptContext) resolveNumericArg(arg string) int { upper := strings.ToUpper(arg) if strings.HasPrefix(upper, "ITEMVAL") { return sc.getVar(upper) @@ -1082,6 +1267,17 @@ func (sc *ScriptContext) resolveNumericArg(arg string) int { } return val } +*/ + +func (sc *ScriptContext) resolveNumericArg(arg string) int { + // First try a literal number. + if val, err := strconv.Atoi(arg); err == nil { + return val + } + + // Otherwise treat it as a script variable. + return sc.getVar(strings.ToUpper(arg)) +} // expandScriptText replaces script placeholders in text. // evalIfCarry checks if player carries an item with matching archetype (and optional adj). @@ -1097,13 +1293,19 @@ func (sc *ScriptContext) evalIfCarry(args []string) bool { if len(args) >= 2 { adj, _ = strconv.Atoi(args[1]) } - for _, ii := range sc.Player.Inventory { + + if sc.Player.HasItem(archetype, adj) { + return true + } + + /*for _, ii := range sc.Player.Inventory { if ii.Archetype == archetype { if adj < 0 || ii.Adj1 == adj { return true } } - } + }*/ + return false } @@ -1136,30 +1338,114 @@ func (sc *ScriptContext) doRandom(args []string) { sc.setVar(varName, rand.Intn(max)) } -// doDamagePlr applies damage to the player. func (sc *ScriptContext) doDamagePlr(args []string) { - if len(args) < 2 { + if len(args) < 1 { return } - // DAMAGEPLR BODYONLY - // or DAMAGEPLR + idx := 0 - if strings.ToUpper(args[0]) == "BODYONLY" { - idx = 1 + bodyOnly := false + damageType := "" + + // Optional BODYONLY keyword. + if strings.EqualFold(args[idx], "BODYONLY") { + bodyOnly = true + idx++ + } + + if idx >= len(args) { + return + } + + // Support both: + // DAMAGEPLR 15 message... + // DAMAGEPLR ELECTRIC 15 message... + if _, err := strconv.Atoi(args[idx]); err != nil { + damageType = strings.ToUpper(args[idx]) + idx++ } + if idx >= len(args) { return } - amount, err := strconv.Atoi(args[idx]) + + /*amount, err := strconv.Atoi(args[idx]) if err != nil { return } - sc.Player.BodyPoints -= amount + idx++ */ + + amount := sc.resolveNumericArg(args[idx]) + idx++ + + finalDamage := amount + + if !bodyOnly && damageType != "" { + finalDamage = sc.applyPlayerResistance(damageType, amount) + } + + if finalDamage < 0 { + finalDamage = 0 + } + + sc.Player.BodyPoints -= finalDamage if sc.Player.BodyPoints < 0 { sc.Player.BodyPoints = 0 } - if idx+1 < len(args) { - text := strings.Join(args[idx+1:], " ") + + if idx < len(args) { + text := strings.Join(args[idx:], " ") + sc.Messages = append(sc.Messages, sc.expandScriptText(text)) + } +} + +func (sc *ScriptContext) applyPlayerResistance(damageType string, amount int) int { + resistance := 0 + + switch strings.ToUpper(damageType) { + case "BURN", "FIRE": + resistance = 0 //todo: sc.Player.FireResistance + case "ELECTRIC", "LIGHTNING": + resistance = 0 //todo sc.Player.ElectricResistance + case "COLD", "ICE": + resistance = 0 //todo: sc.Player.ColdResistance + case "POISON": + resistance = 0 //todo: sc.Player.PoisonResistance + } + + // Assuming resistance is a percentage from 0 to 100. + if resistance < 0 { + resistance = 0 + } + if resistance > 100 { + resistance = 100 + } + + return amount * (100 - resistance) / 100 +} + +func (sc *ScriptContext) doHealPlr(args []string) { + if len(args) < 1 { + return + } + + amount := sc.resolveNumericArg(args[0]) + if amount < 0 { + amount = 0 + } + + oldBP := sc.Player.BodyPoints + + sc.Player.BodyPoints += amount + if sc.Player.BodyPoints > sc.Player.MaxBodyPoints { + sc.Player.BodyPoints = sc.Player.MaxBodyPoints + } + + actualHeal := sc.Player.BodyPoints - oldBP + + if len(args) > 1 { + text := strings.Join(args[1:], " ") + text = strings.ReplaceAll(text, "%D", strconv.Itoa(actualHeal)) sc.Messages = append(sc.Messages, sc.expandScriptText(text)) } } @@ -1414,8 +1700,18 @@ func (sc *ScriptContext) expandScriptText(text string) string { text = strings.ReplaceAll(text, "%i", "her") } // Legacy aliases - text = strings.ReplaceAll(text, "%e", func() string { if sc.Player.Gender == 0 { return "he" }; return "she" }()) - text = strings.ReplaceAll(text, "%o", func() string { if sc.Player.Gender == 0 { return "him" }; return "her" }()) + text = strings.ReplaceAll(text, "%e", func() string { + if sc.Player.Gender == 0 { + return "he" + } + return "she" + }()) + text = strings.ReplaceAll(text, "%o", func() string { + if sc.Player.Gender == 0 { + return "him" + } + return "her" + }()) // STRCVT %0-%9 if sc.StrVars != nil { for i := 0; i <= 9; i++ { @@ -1426,3 +1722,47 @@ func (sc *ScriptContext) expandScriptText(text string) string { } return text } +func (sc *ScriptContext) doSpell(args []string) { + if len(args) == 0 { + return + } + + spellID, err := strconv.Atoi(args[0]) + if err != nil { + return + } + + spell := FindSpellByID(spellID) + if spell == nil { + return + } + + switch spell.Effect { + case "buff": + var result *CommandResult + + if len(args) >= 2 { + duration, err := strconv.Atoi(args[1]) + if err != nil { + return + } + + spell.Duration = time.Duration(duration) * time.Minute + + result = sc.Engine.castBuffSpell( + sc.Player, + spell, + nil, + ) + } else { + result = sc.Engine.castBuffSpell( + sc.Player, + spell, + nil, + ) + } + + _ = result + // instant heal/damage/etc. can use their existing handlers here later + } +} diff --git a/engine/internal/engine/skills.go b/engine/internal/engine/skills.go index 7e9f803..6253775 100644 --- a/engine/internal/engine/skills.go +++ b/engine/internal/engine/skills.go @@ -6,6 +6,8 @@ import ( "math/rand" "strings" "time" + + "github.com/jonradoff/lofp/internal/gameworld" ) // SkillCost defines the build point cost for a skill. @@ -17,42 +19,42 @@ type SkillCost struct { // SkillCosts maps skill ID to build point costs (from skills.txt). var SkillCosts = map[int]SkillCost{ - 0: {10, 5}, // Jeweler - 1: {10, 4}, // Two Weapons - 2: {12, 5}, // Backstab - 3: {12, 5}, // Missile Weapons - 4: {10, 3}, // Natural Weapons (Claws) - 5: {6, 3}, // Climbing - 6: {8, 4}, // Dodging & Parrying - 7: {10, 5}, // Conjuration - 8: {10, 5}, // Weaponsmithing - 9: {12, 5}, // Crushing Weapons - 10: {10, 5}, // Combat Maneuvering - 11: {8, 4}, // Endurance - 12: {6, 3}, // Trap & Poison Lore - 13: {12, 5}, // Edged Weapons - 14: {10, 5}, // Enchantment - 15: {8, 4}, // Dyeing/Weaving - 16: {12, 5}, // Drakin Weapons - 17: {10, 5}, // Druidic Magic - 18: {8, 3}, // Wood Lore - 19: {12, 5}, // Thrown Weapons - 20: {20, 2}, // Healing - 21: {12, 4}, // Legerdemain - 22: {10, 4}, // Lockpicking - 23: {20, 5}, // Spellcraft - 24: {12, 5}, // Martial Arts - 25: {12, 5}, // Polearms - 26: {20, 5}, // Psionics - 27: {10, 5}, // Mind over Mind - 28: {10, 5}, // Mind over Matter - 29: {10, 2}, // Transcendence - 30: {10, 5}, // Necromancy - 31: {15, 5}, // Alchemy - 32: {5, 3}, // Sagecraft - 33: {10, 4}, // Stealth + 0: {10, 5}, // Jeweler + 1: {10, 4}, // Two Weapons + 2: {12, 5}, // Backstab + 3: {12, 5}, // Missile Weapons + 4: {10, 3}, // Natural Weapons (Claws) + 5: {6, 3}, // Climbing + 6: {8, 4}, // Dodging & Parrying + 7: {10, 5}, // Conjuration + 8: {10, 5}, // Weaponsmithing + 9: {12, 5}, // Crushing Weapons + 10: {10, 5}, // Combat Maneuvering + 11: {8, 4}, // Endurance + 12: {6, 3}, // Trap & Poison Lore + 13: {12, 5}, // Edged Weapons + 14: {10, 5}, // Enchantment + 15: {8, 4}, // Dyeing/Weaving + 16: {12, 5}, // Drakin Weapons + 17: {10, 5}, // Druidic Magic + 18: {8, 3}, // Wood Lore + 19: {12, 5}, // Thrown Weapons + 20: {20, 2}, // Healing + 21: {12, 4}, // Legerdemain + 22: {10, 4}, // Lockpicking + 23: {20, 5}, // Spellcraft + 24: {12, 5}, // Martial Arts + 25: {12, 5}, // Polearms + 26: {20, 5}, // Psionics + 27: {10, 5}, // Mind over Mind + 28: {10, 5}, // Mind over Matter + 29: {10, 2}, // Transcendence + 30: {10, 5}, // Necromancy + 31: {15, 5}, // Alchemy + 32: {5, 3}, // Sagecraft + 33: {10, 4}, // Stealth 34: {15, 10}, // Disguise - 35: {8, 4}, // Mining + 35: {8, 4}, // Mining } // skillBPCost returns the build point cost for training to the next rank. @@ -71,13 +73,13 @@ func skillBPCost(skillID, currentRank int) int { // Player must have at least 1 rank in each prerequisite. var SkillPrerequisites = map[int][]int{ 6: {13, 16, 9, 4, 24, 3, 25}, // Dodge: any one weapon skill (OR logic) - 7: {23}, // Conjuration requires Spellcraft - 14: {23}, // Enchantment requires Spellcraft - 17: {23}, // Druidic requires Spellcraft - 30: {23}, // Necromancy requires Spellcraft - 27: {26}, // Mind over Mind requires Psionics - 28: {26}, // Mind over Matter requires Psionics - 34: {33}, // Disguise requires Stealth + 7: {23}, // Conjuration requires Spellcraft + 14: {23}, // Enchantment requires Spellcraft + 17: {23}, // Druidic requires Spellcraft + 30: {23}, // Necromancy requires Spellcraft + 27: {26}, // Mind over Mind requires Psionics + 28: {26}, // Mind over Matter requires Psionics + 34: {33}, // Disguise requires Stealth } // checkPrerequisite returns true if the player meets prerequisites for a skill. @@ -200,7 +202,7 @@ func (e *GameEngine) doTrainWithBP(ctx context.Context, player *Player, args []s goldMsg = fmt.Sprintf(", %d gold", goldCost) } return &CommandResult{ - Messages: []string{fmt.Sprintf("You train in %s to rank %d. (-%d BP%s, %d BP remaining)", name, currentLvl+1, bpCost, goldMsg, player.BuildPoints)}, + Messages: []string{fmt.Sprintf("You train in %s to rank %d. (-%d BP%s, %d BP remaining)", name, currentLvl+1, bpCost, goldMsg, player.BuildPoints)}, PlayerState: player, } } @@ -232,9 +234,9 @@ func (e *GameEngine) doAnoint(ctx context.Context, player *Player, args []string wepName = e.getItemNounName(wepDef) } return &CommandResult{ - Messages: []string{fmt.Sprintf("You carefully apply a level %d poison to your %s.", poisonLevel, wepName)}, + Messages: []string{fmt.Sprintf("You carefully apply a level %d poison to your %s.", poisonLevel, wepName)}, RoomBroadcast: []string{fmt.Sprintf("%s applies something to %s weapon.", player.FirstName, player.Possessive())}, - PlayerState: player, + PlayerState: player, } } @@ -306,16 +308,446 @@ func (e *GameEngine) doTend(ctx context.Context, player *Player, args []string) if target == player { return &CommandResult{ - Messages: []string{fmt.Sprintf("You tend to your wounds, healing %d body points. [Round: 5 sec] [BP: %d/%d]", heal, target.BodyPoints, target.MaxBodyPoints)}, + Messages: []string{fmt.Sprintf("You tend to your wounds, healing %d body points. [Round: 5 sec] [BP: %d/%d]", heal, target.BodyPoints, target.MaxBodyPoints)}, RoomBroadcast: []string{fmt.Sprintf("%s tends to %s wounds.", player.FirstName, player.Possessive())}, - PlayerState: player, + PlayerState: player, } } return &CommandResult{ - Messages: []string{fmt.Sprintf("You tend to %s's wounds, healing %d body points.", targetName, heal)}, + Messages: []string{fmt.Sprintf("You tend to %s's wounds, healing %d body points.", targetName, heal)}, RoomBroadcast: []string{fmt.Sprintf("%s tends to %s's wounds.", player.FirstName, targetName)}, - TargetName: target.FirstName, - TargetMsg: []string{fmt.Sprintf("%s tends to your wounds, healing %d body points. [BP: %d/%d]", player.FirstName, heal, target.BodyPoints, target.MaxBodyPoints)}, + TargetName: target.FirstName, + TargetMsg: []string{fmt.Sprintf("%s tends to your wounds, healing %d body points. [BP: %d/%d]", player.FirstName, heal, target.BodyPoints, target.MaxBodyPoints)}, + } +} + +// ---- PICK (Lockpicking skill) ---- + +func (e *GameEngine) doPick(ctx context.Context, player *Player, args []string) *CommandResult { + + if len(args) == 0 { + return &CommandResult{ + Messages: []string{"Pick what with what?"}, + } + } + + raw := strings.ToLower(strings.Join(args, " ")) + + parts := strings.SplitN(raw, " with ", 2) + if len(parts) != 2 { + return &CommandResult{ + Messages: []string{"Pick what with what?"}, + } + } + + targetName := strings.TrimSpace(parts[0]) + toolName := strings.TrimSpace(parts[1]) + + targetName = strings.TrimPrefix(targetName, "my ") + toolName = strings.TrimPrefix(toolName, "my ") + + if targetName == "" || toolName == "" { + return &CommandResult{ + Messages: []string{"Pick what with what?"}, + } + } + + // Support things like: + // PICK SECOND CHEST WITH LOCKPICK + // PICK CHEST 2 WITH LOCKPICK + targetName, targetSkip := parseOrdinal(targetName) + + room := e.rooms[player.RoomNumber] + if room == nil { + return &CommandResult{ + Messages: []string{"You can't do that here."}, + } + } + + // ------------------------------------------------------------ + // Find the requested lockpick tool in inventory. + // Any item whose type is LOCKPICK qualifies. + // ------------------------------------------------------------ + + toolIndex := -1 + + for i := range player.Inventory { + ii := &player.Inventory[i] + + def := e.items[ii.Archetype] + if def == nil || def.Type != "LOCKPICK" { + continue + } + + noun := e.getItemNounName(def) + + matched := + matchesTarget( + noun, + toolName, + e.getAdjName(ii.Adj1), + ) || + matchesTarget( + noun, + toolName, + e.getAdjName(ii.Adj2), + ) || + matchesTarget( + noun, + toolName, + e.getAdjName(ii.Adj3), + ) + + if !matched { + continue + } + + toolIndex = i + break + } + + if toolIndex < 0 { + return &CommandResult{ + Messages: []string{ + "What are you referring to? Please be more specific.", + }, + } + } + + tool := player.Inventory[toolIndex] + toolDef := e.items[tool.Archetype] + + toolDisplayName := e.formatItemName( + toolDef, + tool.Adj1, + tool.Adj2, + tool.Adj3, + ) + + // ------------------------------------------------------------ + // Find target. + // + // Inventory first, then room. + // Only containers are supported for now. + // ------------------------------------------------------------ + + var inventoryTarget *InventoryItem + var roomTarget *gameworld.RoomItem + var targetDef *gameworld.ItemDef + + // ------------------------------------------------------------ + // Search carried containers. + // ------------------------------------------------------------ + + skip := targetSkip + + for i := range player.Inventory { + ii := &player.Inventory[i] + + def := e.items[ii.Archetype] + if def == nil { + continue + } + + // For now PICK only supports containers. + if def.Container == "" { + continue + } + + noun := e.getItemNounName(def) + + matched := + matchesTarget( + noun, + targetName, + e.getAdjName(ii.Adj1), + ) || + matchesTarget( + noun, + targetName, + e.getAdjName(ii.Adj2), + ) || + matchesTarget( + noun, + targetName, + e.getAdjName(ii.Adj3), + ) + + if !matched { + continue + } + + if skip > 0 { + skip-- + continue + } + + inventoryTarget = ii + targetDef = def + break + } + + // ------------------------------------------------------------ + // If not carried, search room containers. + // ------------------------------------------------------------ + + if inventoryTarget == nil { + skip = targetSkip + + for i := range room.Items { + ri := &room.Items[i] + + // Don't target something inside another container. + if ri.IsPut { + continue + } + + def := e.items[ri.Archetype] + if def == nil { + continue + } + + // For now PICK only supports containers. + if !containsFlag(def.Flags, "LOCKABLE") { + continue + } + + noun := e.getItemNounName(def) + + matched := + matchesTarget( + noun, + targetName, + e.getAdjName(ri.Adj1), + ) || + matchesTarget( + noun, + targetName, + e.getAdjName(ri.Adj2), + ) || + matchesTarget( + noun, + targetName, + e.getAdjName(ri.Adj3), + ) + + if !matched { + continue + } + + if skip > 0 { + skip-- + continue + } + + roomTarget = ri + targetDef = def + break + } + } + + if inventoryTarget == nil && roomTarget == nil { + return &CommandResult{ + Messages: []string{"You don't see that here."}, + } + } + + if targetDef == nil { + return &CommandResult{ + Messages: []string{"You can't pick that."}, + } + } + + // ------------------------------------------------------------ + // Make sure the target actually has a lock. + // ------------------------------------------------------------ + + if !containsFlag(targetDef.Flags, "LOCKABLE") { + return &CommandResult{ + Messages: []string{"That doesn't have a lock."}, + } + } + + state := "" + lockDifficulty := 0 + + if inventoryTarget != nil { + state = inventoryTarget.State + lockDifficulty = inventoryTarget.Val1 + } else { + state = roomTarget.State + lockDifficulty = roomTarget.Val1 + } + + if !strings.EqualFold(state, "LOCKED") { + return &CommandResult{ + Messages: []string{"It isn't locked."}, + } } + + // ------------------------------------------------------------ + // Lockpicking skill. + // + // Skill #22 = Lockpicking + // + // 1 rank = 30% base + // each additional rank = +5% + // every 10 Perception = +1% + // VAL1 = lock difficulty + // + // Chance = + // 30 + // + (ranks - 1) * 5 + // + PER / 10 + // - VAL1 + // ------------------------------------------------------------ + + lockpickRanks := player.Skills[22] + + if lockpickRanks < 1 { + return &CommandResult{ + Messages: []string{ + "You have no training in Lockpicking.", + }, + } + } + + perception := player.Perception + + chance := 30 + + (lockpickRanks-1)*5 + + perception/10 - + lockDifficulty + + if chance < 1 { + chance = 1 + } + + if chance > 95 { + chance = 95 + } + + roll := rand.Intn(100) + 1 + + // ------------------------------------------------------------ + // Target display name. + // ------------------------------------------------------------ + + var displayName string + + if inventoryTarget != nil { + displayName = e.formatItemName( + targetDef, + inventoryTarget.Adj1, + inventoryTarget.Adj2, + inventoryTarget.Adj3, + ) + } else { + displayName = e.formatItemName( + targetDef, + roomTarget.Adj1, + roomTarget.Adj2, + roomTarget.Adj3, + ) + } + + // Every attempt takes 5 seconds. + player.RoundTimeExpiry = time.Now().Add(5 * time.Second) + + result := &CommandResult{} + + // ------------------------------------------------------------ + // SUCCESS + // ------------------------------------------------------------ + + if roll <= chance { + + // Picking unlocks the container but does not open it. + if inventoryTarget != nil { + inventoryTarget.State = "CLOSED" + } else { + roomTarget.State = "CLOSED" + } + + result.Messages = append( + result.Messages, + fmt.Sprintf( + "[Success: %d%%, Roll: %d] Success!", + chance, + roll, + ), + fmt.Sprintf( + "You hear a click as you succeed in picking the lock on %s.", + displayName, + ), + " [Round: 5 sec]", + ) + + result.RoomBroadcast = append( + result.RoomBroadcast, + fmt.Sprintf( + "%s works at the lock on %s.", + player.FirstName, + displayName, + ), + ) + + e.SavePlayer(ctx, player) + + return result + } + + // ------------------------------------------------------------ + // EXTREME FAILURE + // ------------------------------------------------------------ + + if roll >= 99 { + + result.Messages = append( + result.Messages, + fmt.Sprintf( + "[Success: %d%%, Roll: %d] Extreme failure!", + chance, + roll, + ), + fmt.Sprintf( + "You snap %s in the attempt!", + toolDisplayName, + ), + " [Round: 5 sec]", + ) + + // Destroy the selected lockpick/hairpin. + player.Inventory = append( + player.Inventory[:toolIndex], + player.Inventory[toolIndex+1:]..., + ) + + e.SavePlayer(ctx, player) + + return result + } + + // ------------------------------------------------------------ + // NORMAL FAILURE + // ------------------------------------------------------------ + + result.Messages = append( + result.Messages, + fmt.Sprintf( + "[Success: %d%%, Roll: %d] Failure.", + chance, + roll, + ), + fmt.Sprintf( + "You fail to pick the lock on %s.", + displayName, + ), + " [Round: 5 sec]", + ) + + e.SavePlayer(ctx, player) + + return result } diff --git a/engine/internal/engine/spells.go b/engine/internal/engine/spells.go index cd174eb..3cdbeb6 100644 --- a/engine/internal/engine/spells.go +++ b/engine/internal/engine/spells.go @@ -15,14 +15,19 @@ type SpellDef struct { School string Level int ManaCost int - CastTime int // seconds + CastTime int // seconds Effect string // "damage", "heal", "defense", "buff", "utility" DmgMin int DmgMax int HealMin int HealMax int DefBonus int - DmgType string // "heat", "cold", "electric", "crushing", "" + DmgType string // "heat", "cold", "electric", "crushing", "" + Duration time.Duration // seconds; 0 = instant/permanent + Family string // "", "agility", "strength", "armor", etc. + + StatusType StatID + StatusMsg string } // spellRegistry holds all defined spells. @@ -33,15 +38,15 @@ func init() { conj := []SpellDef{ {ID: 100, Name: "Flame Bolt", School: "Conjuration", Level: 1, ManaCost: 3, CastTime: 3, Effect: "damage", DmgMin: 3, DmgMax: 12, DmgType: "heat"}, {ID: 101, Name: "Force Blade", School: "Conjuration", Level: 3, ManaCost: 5, CastTime: 3, Effect: "damage", DmgMin: 5, DmgMax: 18, DmgType: ""}, - {ID: 102, Name: "Mystic Armor", School: "Conjuration", Level: 5, ManaCost: 8, CastTime: 3, Effect: "defense", DefBonus: 20}, + {ID: 102, Name: "Mystic Armor", School: "Conjuration", Level: 5, ManaCost: 8, CastTime: 3, Effect: "defense", DefBonus: 20, Duration: 30 * time.Minute, Family: "armor", StatusType: DefensiveBuff}, {ID: 103, Name: "Lightning Bolt", School: "Conjuration", Level: 7, ManaCost: 10, CastTime: 3, Effect: "damage", DmgMin: 8, DmgMax: 30, DmgType: "electric"}, - {ID: 105, Name: "Globe of Protection", School: "Conjuration", Level: 15, ManaCost: 20, CastTime: 3, Effect: "defense", DefBonus: 50}, + {ID: 105, Name: "Globe of Protection", School: "Conjuration", Level: 15, ManaCost: 20, CastTime: 3, Effect: "defense", DefBonus: 50, Duration: 90 * time.Minute, Family: "armor", StatusType: DefensiveBuff}, {ID: 106, Name: "Summon Fire Elemental", School: "Conjuration", Level: 12, ManaCost: 25, CastTime: 5, Effect: "utility"}, {ID: 107, Name: "Summon Air Elemental", School: "Conjuration", Level: 12, ManaCost: 25, CastTime: 5, Effect: "utility"}, {ID: 108, Name: "Summon Water Elemental", School: "Conjuration", Level: 12, ManaCost: 25, CastTime: 5, Effect: "utility"}, {ID: 109, Name: "Summon Gargoyle", School: "Conjuration", Level: 16, ManaCost: 30, CastTime: 5, Effect: "utility"}, {ID: 112, Name: "Call Meteor", School: "Conjuration", Level: 20, ManaCost: 30, CastTime: 4, Effect: "damage", DmgMin: 25, DmgMax: 60, DmgType: "heat"}, - {ID: 113, Name: "Light", School: "Conjuration", Level: 1, ManaCost: 2, CastTime: 2, Effect: "utility"}, + {ID: 113, Name: "Light", School: "Conjuration", Level: 1, ManaCost: 2, CastTime: 2, Effect: "buff", Family: "light", Duration: 30 * time.Minute, StatusType: LightBuff, StatusMsg: "A soft light surrounds you."}, {ID: 114, Name: "Mystic Key", School: "Conjuration", Level: 2, ManaCost: 4, CastTime: 3, Effect: "utility"}, {ID: 115, Name: "Shockwave", School: "Conjuration", Level: 4, ManaCost: 6, CastTime: 3, Effect: "damage", DmgMin: 4, DmgMax: 15, DmgType: "crushing"}, {ID: 116, Name: "Thunder Call", School: "Conjuration", Level: 21, ManaCost: 28, CastTime: 4, Effect: "damage", DmgMin: 20, DmgMax: 50, DmgType: "electric"}, @@ -56,10 +61,10 @@ func init() { {ID: 125, Name: "Thunder Glyph", School: "Conjuration", Level: 10, ManaCost: 15, CastTime: 3, Effect: "damage", DmgMin: 12, DmgMax: 30, DmgType: "electric"}, {ID: 126, Name: "Ice Glyph", School: "Conjuration", Level: 15, ManaCost: 20, CastTime: 3, Effect: "damage", DmgMin: 15, DmgMax: 40, DmgType: "cold"}, {ID: 127, Name: "Web", School: "Conjuration", Level: 10, ManaCost: 12, CastTime: 3, Effect: "utility"}, - {ID: 130, Name: "Mass Protection", School: "Conjuration", Level: 23, ManaCost: 30, CastTime: 4, Effect: "defense", DefBonus: 25}, + {ID: 130, Name: "Mass Protection", School: "Conjuration", Level: 23, ManaCost: 30, CastTime: 4, Effect: "defense", DefBonus: 25, Duration: 45 * time.Minute, Family: "armor"}, {ID: 131, Name: "Flaming Arrows", School: "Conjuration", Level: 18, ManaCost: 22, CastTime: 3, Effect: "damage", DmgMin: 15, DmgMax: 35, DmgType: "heat"}, {ID: 132, Name: "Chain Lightning", School: "Conjuration", Level: 23, ManaCost: 28, CastTime: 4, Effect: "damage", DmgMin: 20, DmgMax: 50, DmgType: "electric"}, - {ID: 133, Name: "Globe of Protection II", School: "Conjuration", Level: 30, ManaCost: 40, CastTime: 4, Effect: "defense", DefBonus: 100}, + {ID: 133, Name: "Globe of Protection II", School: "Conjuration", Level: 30, ManaCost: 40, CastTime: 4, Effect: "defense", DefBonus: 100, Duration: 90 * time.Minute, Family: "armor"}, {ID: 134, Name: "Siryx's Terrible Tentacles", School: "Conjuration", Level: 25, ManaCost: 35, CastTime: 4, Effect: "damage", DmgMin: 20, DmgMax: 55, DmgType: "crushing"}, {ID: 135, Name: "Storm Blade", School: "Conjuration", Level: 24, ManaCost: 30, CastTime: 3, Effect: "buff"}, {ID: 136, Name: "Inferno Blade", School: "Conjuration", Level: 19, ManaCost: 25, CastTime: 3, Effect: "buff"}, @@ -71,20 +76,20 @@ func init() { ench := []SpellDef{ {ID: 200, Name: "Fear", School: "Enchantment", Level: 1, ManaCost: 3, CastTime: 3, Effect: "utility"}, {ID: 201, Name: "Charm", School: "Enchantment", Level: 3, ManaCost: 8, CastTime: 3, Effect: "utility"}, - {ID: 202, Name: "Enchantment I", School: "Enchantment", Level: 5, ManaCost: 10, CastTime: 4, Effect: "buff"}, - {ID: 207, Name: "Strength I", School: "Enchantment", Level: 4, ManaCost: 6, CastTime: 3, Effect: "buff"}, - {ID: 208, Name: "Strength II", School: "Enchantment", Level: 8, ManaCost: 10, CastTime: 3, Effect: "buff"}, - {ID: 209, Name: "Strength III", School: "Enchantment", Level: 16, ManaCost: 18, CastTime: 3, Effect: "buff"}, - {ID: 210, Name: "Haste", School: "Enchantment", Level: 5, ManaCost: 8, CastTime: 3, Effect: "buff"}, - {ID: 211, Name: "Slow", School: "Enchantment", Level: 5, ManaCost: 8, CastTime: 3, Effect: "utility"}, + {ID: 202, Name: "Enchantment I", School: "Enchantment", Level: 5, ManaCost: 10, CastTime: 4, Effect: "buff", Duration: 45 * time.Minute, Family: "enchantment"}, + {ID: 207, Name: "Strength I", School: "Enchantment", Level: 4, ManaCost: 6, CastTime: 3, Effect: "buff", DefBonus: 10, Duration: 30 * time.Minute, Family: "strength", StatusType: StatStrength}, + {ID: 208, Name: "Strength II", School: "Enchantment", Level: 8, ManaCost: 10, CastTime: 3, Effect: "buff", DefBonus: 20, Duration: 45 * time.Minute, Family: "strength", StatusType: StatStrength}, + {ID: 209, Name: "Strength III", School: "Enchantment", Level: 16, ManaCost: 18, CastTime: 3, Effect: "buff", DefBonus: 30, Duration: 60 * time.Minute, Family: "strength", StatusType: StatStrength}, + {ID: 210, Name: "Haste", School: "Enchantment", Level: 5, ManaCost: 8, CastTime: 3, Effect: "buff", Duration: 60 * time.Minute, Family: "haste"}, + {ID: 211, Name: "Slow", School: "Enchantment", Level: 5, ManaCost: 8, CastTime: 3, Effect: "utility", Duration: 60 * time.Minute, Family: "slow"}, {ID: 216, Name: "Slumber I", School: "Enchantment", Level: 2, ManaCost: 4, CastTime: 3, Effect: "utility"}, {ID: 219, Name: "Silence", School: "Enchantment", Level: 7, ManaCost: 10, CastTime: 3, Effect: "utility"}, - {ID: 224, Name: "Fly", School: "Enchantment", Level: 11, ManaCost: 15, CastTime: 3, Effect: "buff"}, - {ID: 225, Name: "Invisibility", School: "Enchantment", Level: 14, ManaCost: 18, CastTime: 3, Effect: "buff"}, + {ID: 224, Name: "Fly", School: "Enchantment", Level: 11, ManaCost: 15, CastTime: 3, Effect: "buff", Duration: 45 * time.Minute}, + {ID: 225, Name: "Invisibility", School: "Enchantment", Level: 14, ManaCost: 18, CastTime: 3, Effect: "buff", Duration: 45 * time.Minute}, {ID: 228, Name: "Identify", School: "Enchantment", Level: 7, ManaCost: 5, CastTime: 3, Effect: "utility"}, - {ID: 229, Name: "Wizard's Armor", School: "Enchantment", Level: 9, ManaCost: 12, CastTime: 3, Effect: "defense", DefBonus: 15}, - {ID: 234, Name: "Spell Shield", School: "Enchantment", Level: 13, ManaCost: 15, CastTime: 3, Effect: "defense", DefBonus: 25}, - {ID: 235, Name: "Cloak Mind", School: "Enchantment", Level: 22, ManaCost: 25, CastTime: 3, Effect: "defense", DefBonus: 25}, + {ID: 229, Name: "Wizard's Armor", School: "Enchantment", Level: 9, ManaCost: 12, CastTime: 3, Effect: "defense", DefBonus: 15, Duration: 45 * time.Minute}, + {ID: 234, Name: "Spell Shield", School: "Enchantment", Level: 13, ManaCost: 15, CastTime: 3, Effect: "defense", DefBonus: 25, Duration: 45 * time.Minute}, + {ID: 235, Name: "Cloak Mind", School: "Enchantment", Level: 22, ManaCost: 25, CastTime: 3, Effect: "defense", DefBonus: 25, Duration: 45 * time.Minute}, } // Necromancy (301-356) necro := []SpellDef{ @@ -97,7 +102,7 @@ func init() { {ID: 317, Name: "Body Restoration II", School: "Necromancy", Level: 5, ManaCost: 7, CastTime: 3, Effect: "heal", HealMin: 10, HealMax: 30}, {ID: 318, Name: "Body Restoration III", School: "Necromancy", Level: 10, ManaCost: 14, CastTime: 3, Effect: "heal", HealMin: 20, HealMax: 50}, {ID: 323, Name: "Spectral Fist", School: "Necromancy", Level: 3, ManaCost: 5, CastTime: 3, Effect: "damage", DmgMin: 4, DmgMax: 14, DmgType: "crushing"}, - {ID: 326, Name: "Spectral Shield", School: "Necromancy", Level: 9, ManaCost: 12, CastTime: 3, Effect: "defense", DefBonus: 20}, + {ID: 326, Name: "Spectral Shield", School: "Necromancy", Level: 9, ManaCost: 12, CastTime: 3, Effect: "defense", DefBonus: 20, Duration: 45 * time.Minute, StatusType: DefensiveBuff}, {ID: 334, Name: "Invigoration I", School: "Necromancy", Level: 2, ManaCost: 4, CastTime: 3, Effect: "heal", HealMin: 3, HealMax: 10}, {ID: 335, Name: "Invigoration II", School: "Necromancy", Level: 9, ManaCost: 10, CastTime: 3, Effect: "heal", HealMin: 8, HealMax: 25}, {ID: 337, Name: "Reconstruction", School: "Necromancy", Level: 4, ManaCost: 6, CastTime: 3, Effect: "heal", HealMin: 5, HealMax: 20}, @@ -107,14 +112,14 @@ func init() { {ID: 341, Name: "Destroy Undead III", School: "Necromancy", Level: 13, ManaCost: 20, CastTime: 3, Effect: "damage", DmgMin: 25, DmgMax: 60, DmgType: ""}, {ID: 343, Name: "Regeneration", School: "Necromancy", Level: 27, ManaCost: 35, CastTime: 4, Effect: "heal", HealMin: 40, HealMax: 80}, {ID: 345, Name: "Spectral Sword", School: "Necromancy", Level: 7, ManaCost: 10, CastTime: 3, Effect: "damage", DmgMin: 6, DmgMax: 22, DmgType: ""}, - {ID: 347, Name: "Divine Blessing", School: "Necromancy", Level: 10, ManaCost: 12, CastTime: 3, Effect: "buff"}, + {ID: 347, Name: "Divine Blessing", School: "Necromancy", Level: 10, ManaCost: 12, CastTime: 3, Effect: "buff", Duration: 45 * time.Minute}, {ID: 354, Name: "Rorin's Fire", School: "Necromancy", Level: 17, ManaCost: 22, CastTime: 3, Effect: "damage", DmgMin: 15, DmgMax: 40, DmgType: "heat"}, } // General (400-415) gen := []SpellDef{ {ID: 400, Name: "Detect Magic", School: "General", Level: 1, ManaCost: 2, CastTime: 2, Effect: "utility"}, {ID: 401, Name: "Dispel Lesser Magic", School: "General", Level: 5, ManaCost: 8, CastTime: 3, Effect: "utility"}, - {ID: 403, Name: "Mindlink", School: "General", Level: 9, ManaCost: 12, CastTime: 3, Effect: "utility"}, + {ID: 403, Name: "Mindlink", School: "General", Level: 9, ManaCost: 12, CastTime: 3, Effect: "utility", Duration: 45 * time.Minute}, {ID: 405, Name: "See Hidden", School: "General", Level: 3, ManaCost: 5, CastTime: 3, Effect: "utility"}, {ID: 406, Name: "Dispel Invisibility", School: "General", Level: 8, ManaCost: 10, CastTime: 3, Effect: "utility"}, {ID: 407, Name: "Analyze Ore", School: "General", Level: 3, ManaCost: 4, CastTime: 3, Effect: "utility"}, @@ -123,16 +128,16 @@ func init() { druid := []SpellDef{ {ID: 500, Name: "Plant Snare", School: "Druidic", Level: 4, ManaCost: 6, CastTime: 3, Effect: "utility"}, {ID: 505, Name: "Freedom", School: "Druidic", Level: 9, ManaCost: 12, CastTime: 3, Effect: "utility"}, - {ID: 507, Name: "Heat Shield", School: "Druidic", Level: 7, ManaCost: 10, CastTime: 3, Effect: "buff"}, - {ID: 508, Name: "Cold Shield", School: "Druidic", Level: 6, ManaCost: 8, CastTime: 3, Effect: "buff"}, - {ID: 511, Name: "Carapace", School: "Druidic", Level: 8, ManaCost: 10, CastTime: 3, Effect: "defense", DefBonus: 20}, + {ID: 507, Name: "Heat Shield", School: "Druidic", Level: 7, ManaCost: 10, CastTime: 3, Effect: "buff", Duration: 45 * time.Minute}, + {ID: 508, Name: "Cold Shield", School: "Druidic", Level: 6, ManaCost: 8, CastTime: 3, Effect: "buff", Duration: 45 * time.Minute}, + {ID: 511, Name: "Carapace", School: "Druidic", Level: 8, ManaCost: 10, CastTime: 3, Effect: "defense", DefBonus: 20, Duration: 45 * time.Minute}, {ID: 512, Name: "True Aim", School: "Druidic", Level: 15, ManaCost: 18, CastTime: 3, Effect: "buff"}, - {ID: 513, Name: "Agility I", School: "Druidic", Level: 4, ManaCost: 6, CastTime: 3, Effect: "buff"}, - {ID: 514, Name: "Agility II", School: "Druidic", Level: 11, ManaCost: 12, CastTime: 3, Effect: "buff"}, - {ID: 515, Name: "Agility III", School: "Druidic", Level: 16, ManaCost: 20, CastTime: 3, Effect: "buff"}, + {ID: 513, Name: "Agility I", School: "Druidic", Level: 4, ManaCost: 6, CastTime: 3, Effect: "buff", DefBonus: 10, Duration: 30 * time.Minute, Family: "agility", StatusType: StatAgility}, + {ID: 514, Name: "Agility II", School: "Druidic", Level: 11, ManaCost: 12, CastTime: 3, Effect: "buff", DefBonus: 20, Duration: 45 * time.Minute, Family: "agility", StatusType: StatAgility}, + {ID: 515, Name: "Agility III", School: "Druidic", Level: 16, ManaCost: 20, CastTime: 3, Effect: "buff", DefBonus: 30, Duration: 60 * time.Minute, Family: "agility", StatusType: StatAgility}, {ID: 519, Name: "Sunray", School: "Druidic", Level: 13, ManaCost: 18, CastTime: 3, Effect: "damage", DmgMin: 12, DmgMax: 35, DmgType: "heat"}, - {ID: 520, Name: "Night Vision", School: "Druidic", Level: 1, ManaCost: 2, CastTime: 2, Effect: "utility"}, - {ID: 521, Name: "Camouflage", School: "Druidic", Level: 7, ManaCost: 8, CastTime: 3, Effect: "buff"}, + {ID: 520, Name: "Night Vision", School: "Druidic", Level: 1, ManaCost: 2, CastTime: 2, Effect: "buff", Duration: 45 * time.Minute, Family: "nightvision", StatusType: NightVisionBuff, StatusMsg: "Your eyes adjust to the darkness."}, + {ID: 521, Name: "Camouflage", School: "Druidic", Level: 7, ManaCost: 8, CastTime: 3, Effect: "buff", Duration: 45 * time.Minute}, {ID: 523, Name: "Earth Spike", School: "Druidic", Level: 5, ManaCost: 7, CastTime: 3, Effect: "damage", DmgMin: 5, DmgMax: 18, DmgType: "crushing"}, {ID: 524, Name: "Earth Wave", School: "Druidic", Level: 12, ManaCost: 16, CastTime: 3, Effect: "damage", DmgMin: 10, DmgMax: 30, DmgType: "crushing"}, } @@ -192,6 +197,110 @@ func spellSchoolSkill(school string) int { } } +// doLearn handles the LEARN command — learn a spell from a scroll. +// The scroll's Val3 holds the spell number. The player must have the +// appropriate magic school skill at a sufficient level. +func (e *GameEngine) doLearn(ctx context.Context, player *Player, args []string) *CommandResult { + if len(args) == 0 { + return &CommandResult{Messages: []string{"Learn from what?"}} + } + target := strings.ToLower(strings.Join(args, " ")) + target = strings.TrimPrefix(target, "my ") + target, ordSkip := parseOrdinal(target) + skip := ordSkip + + for i, ii := range player.Inventory { + itemDef := e.items[ii.Archetype] + if itemDef == nil { + continue + } + if !strings.Contains(strings.ToUpper(itemDef.Type), "SCROLL") { + continue + } + name := e.getItemNounName(itemDef) + if !matchesTarget(name, target, e.getAdjName(ii.Adj1)) { + continue + } + if skip > 0 { + skip-- + continue + } + + spellNum := ii.Val3 + if spellNum == 0 { + return &CommandResult{Messages: []string{"This scroll holds no magical inscription."}} + } + + spell := FindSpellByID(spellNum) + if spell == nil { + return &CommandResult{Messages: []string{"The scroll's magic is beyond comprehension."}} + } + + // Check if already known + if player.KnownSpells != nil { + if _, known := player.KnownSpells[spellNum]; known { + return &CommandResult{Messages: []string{fmt.Sprintf("You already know %s.", spell.Name)}} + } + } + + // Map spell school name to required skill ID + requiredSkill := schoolSkillID(spell.School) + if requiredSkill < 0 { + return &CommandResult{Messages: []string{"You cannot learn spells of that school."}} + } + + // Player must have the school skill at a level >= spell level + playerSkillLevel := player.Skills[requiredSkill] + if playerSkillLevel < spell.Level { + return &CommandResult{Messages: []string{ + fmt.Sprintf("You need %s rank %d to learn %s (you have rank %d).", + SkillNames[requiredSkill], spell.Level, spell.Name, playerSkillLevel), + }} + } + + // Consume the scroll and add the spell + fullName := e.formatItemName(itemDef, ii.Adj1, ii.Adj2, ii.Adj3) + player.Inventory = append(player.Inventory[:i], player.Inventory[i+1:]...) + if player.KnownSpells == nil { + player.KnownSpells = make(map[int]bool) + } + player.KnownSpells[spellNum] = true + + player.RoundTimeExpiry = time.Now().Add(5 * time.Second) + e.SavePlayer(ctx, player) + return &CommandResult{ + Messages: []string{ + fmt.Sprintf("You study %s carefully...", fullName), + fmt.Sprintf("You learn %s! The scroll crumbles to dust.", spell.Name), + "[Round: 5 sec]", + }, + RoomBroadcast: []string{ + fmt.Sprintf("%s studies a scroll, which crumbles away.", player.FirstName), + }, + } + } + return &CommandResult{Messages: []string{"You don't have that."}} +} + +// schoolSkillID returns the skill ID required for a given magic school name. +// Returns -1 if the school is unknown. +func schoolSkillID(school string) int { + switch strings.ToLower(school) { + case "conjuration": + return 7 + case "enchantment": + return 14 + case "druidic": + return 17 + case "general": + return 23 // Spellcraft + case "necromancy": + return 30 + default: + return -1 + } +} + // doPrepareSpell handles PREPARE/INVOKE . func (e *GameEngine) doPrepareSpell(player *Player, args []string) *CommandResult { if len(args) == 0 { @@ -275,7 +384,7 @@ func (e *GameEngine) doCastSpell(ctx context.Context, player *Player, args []str // Base 25% + EMP/10 + spellcraft*5%, max 95%. // Roll > 98 = fumble. Roll <= 2 = spectacular success (double effect). spellcraftSkill := player.Skills[23] - castChance := 25 + player.Empathy/10 + spellcraftSkill*5 + castChance := 25 + player.EffectiveStat(StatEmpathy)/10 + spellcraftSkill*5 if castChance > 95 { castChance = 95 } @@ -316,12 +425,10 @@ func (e *GameEngine) doCastSpell(ctx context.Context, player *Player, args []str result = e.castDamageSpell(player, spell, args, spectacularSuccess) case "heal": result = e.castHealSpell(ctx, player, spell, args) - case "defense": - player.DefenseBonus += spell.DefBonus - result.Messages = []string{fmt.Sprintf("You gesture and %s takes effect! (+%d defense)", spell.Name, spell.DefBonus)} - result.RoomBroadcast = []string{fmt.Sprintf("%s gestures and casts %s.", player.FirstName, spell.Name)} + case "defense": //todo: add defensive spell effects to player stats + result = e.castStatusSpell(player, spell, args) case "buff": - result = e.castBuffSpell(player, spell, args) + result = e.castStatusSpell(player, spell, args) default: result.Messages = []string{fmt.Sprintf("You gesture and cast %s.", spell.Name)} result.RoomBroadcast = []string{fmt.Sprintf("%s gestures and casts %s.", player.FirstName, spell.Name)} @@ -336,6 +443,48 @@ func (e *GameEngine) doCastSpell(ctx context.Context, player *Player, args []str return result } +func (e *GameEngine) castStatusSpell(player *Player, spell *SpellDef, args []string) *CommandResult { + + duration := spell.Duration + if duration == 0 { + duration = 30 * time.Minute + } + + if !prepareSpellFamilyEffect(player, spell) { + return &CommandResult{ + Messages: []string{ + fmt.Sprintf( + "You gesture and cast %s, but a stronger %s spell is already affecting you.", + spell.Name, + spell.Family, + ), + }, + } + } + + player.ApplyStatEffect( + spell.ID, + EffectSourceSpell, + spell.StatusType, + spell.DefBonus, + duration, + ) + + msg := spell.StatusMsg + if strings.Contains(msg, "%d") { + msg = fmt.Sprintf(msg, spell.DefBonus) + } + + return &CommandResult{ + Messages: []string{ + fmt.Sprintf("You gesture and cast %s. %s", spell.Name, msg), + }, + RoomBroadcast: []string{ + fmt.Sprintf("%s gestures and casts %s.", player.FirstName, spell.Name), + }, + } +} + func (e *GameEngine) castDamageSpell(player *Player, spell *SpellDef, args []string, spectacular bool) *CommandResult { // Find target targetName := "" @@ -418,7 +567,7 @@ func (e *GameEngine) castDamageSpell(player *Player, spell *SpellDef, args []str flavorDmg = fmt.Sprintf("%s strike to %s. [%d Damage]", damageSeverity(dmg), randomBodyPart(def.BodyType), dmg) } - killed := e.damageMonster(inst.ID, dmg) + killed := e.damageMonster(player, inst.ID, dmg) var msgs, roomMsgs []string msgs = append(msgs, fmt.Sprintf("You gesture at %s%s.", article, name)) @@ -483,6 +632,28 @@ func (e *GameEngine) castHealSpell(ctx context.Context, player *Player, spell *S func (e *GameEngine) castBuffSpell(player *Player, spell *SpellDef, args []string) *CommandResult { msg := fmt.Sprintf("You gesture and cast %s.", spell.Name) + + buffDuration := spell.Duration + if buffDuration == 0 { + buffDuration = 30 * time.Minute + } + + // Check for existing spell family effect + if !prepareSpellFamilyEffect(player, spell) { + return &CommandResult{ + Messages: []string{ + fmt.Sprintf( + "You gesture and cast %s, but a stronger %s spell is already affecting you.", + spell.Name, + spell.Family, + ), + }, + RoomBroadcast: []string{ + fmt.Sprintf("%s gestures and casts %s.", player.FirstName, spell.Name), + }, + } + } + switch spell.ID { case 202: // Enchantment I — enchant a weapon in inventory if len(args) == 0 { @@ -511,39 +682,80 @@ func (e *GameEngine) castBuffSpell(player *Player, spell *SpellDef, args []strin } } return &CommandResult{Messages: []string{"You don't have a weapon matching that."}} - case 207: // Strength I - player.Strength += 10 - msg = fmt.Sprintf("You gesture and cast %s. You feel stronger! (+10 STR)", spell.Name) - case 208: // Strength II - player.Strength += 20 - msg = fmt.Sprintf("You gesture and cast %s. You feel much stronger! (+20 STR)", spell.Name) - case 209: // Strength III - player.Strength += 30 - msg = fmt.Sprintf("You gesture and cast %s. Immense strength surges through you! (+30 STR)", spell.Name) - case 210: // Haste - msg = fmt.Sprintf("You gesture and cast %s. The world seems to slow down around you.", spell.Name) - case 224: // Fly - player.CanFly = true - msg = fmt.Sprintf("You gesture and cast %s. You rise into the air!", spell.Name) - case 225: // Invisibility - player.Invisible = true - msg = fmt.Sprintf("You gesture and cast %s. You fade from sight.", spell.Name) - case 513: // Agility I - player.Agility += 10 - msg = fmt.Sprintf("You gesture and cast %s. You feel more agile! (+10 AGI)", spell.Name) - case 514: // Agility II - player.Agility += 20 - msg = fmt.Sprintf("You gesture and cast %s. You feel much more agile! (+20 AGI)", spell.Name) - case 515: // Agility III - player.Agility += 30 - msg = fmt.Sprintf("You gesture and cast %s. Incredible agility flows through you! (+30 AGI)", spell.Name) + + case 207, 208, 209: // Strength I-III + player.ApplyStatEffect( + spell.ID, + EffectSourceSpell, + StatStrength, + spell.DefBonus, + buffDuration, + ) + + msg = fmt.Sprintf("You gesture and cast %s. Your strength increases! (+%d STR)", spell.Name, spell.DefBonus) + + case 513, 514, 515: // Agility I-III + player.ApplyStatEffect( + spell.ID, + EffectSourceSpell, + StatAgility, + spell.DefBonus, + buffDuration, + ) + + msg = fmt.Sprintf("You gesture and cast %s. Your agility increases! (+%d AGI)", spell.Name, spell.DefBonus) } + return &CommandResult{ Messages: []string{msg}, RoomBroadcast: []string{fmt.Sprintf("%s gestures and casts %s.", player.FirstName, spell.Name)}, } } +func (e *GameEngine) castDefenseSpell(player *Player, spell *SpellDef, args []string) *CommandResult { + + buffDuration := spell.Duration + if buffDuration == 0 { + buffDuration = 30 * time.Minute + } + + if !prepareSpellFamilyEffect(player, spell) { + return &CommandResult{ + Messages: []string{ + fmt.Sprintf( + "You gesture and cast %s, but a stronger %s spell is already affecting you.", + spell.Name, + spell.Family, + ), + }, + RoomBroadcast: []string{ + fmt.Sprintf("%s gestures and casts %s.", player.FirstName, spell.Name), + }, + } + } + + player.ApplyStatEffect( + spell.ID, + EffectSourceSpell, + DefensiveBuff, + spell.DefBonus, + buffDuration, + ) + + return &CommandResult{ + Messages: []string{ + fmt.Sprintf( + "You gesture and cast %s. A protective force surrounds you! (+%d defense)", + spell.Name, + spell.DefBonus, + ), + }, + RoomBroadcast: []string{ + fmt.Sprintf("%s gestures and casts %s.", player.FirstName, spell.Name), + }, + } +} + func elementalImmunityType(dmgType string) int { switch strings.ToLower(dmgType) { case "heat": @@ -574,3 +786,40 @@ func spellDmgNoun(dmgType string) string { return "blast" } } + +func prepareSpellFamilyEffect(player *Player, spell *SpellDef) bool { + if spell.Family == "" { + return true + } + + for i := len(player.ActiveStatEffects) - 1; i >= 0; i-- { + effect := player.ActiveStatEffects[i] + + if effect.Source != EffectSourceSpell { + continue + } + + existingSpell := FindSpellByID(effect.EffectID) + if existingSpell == nil || existingSpell.Family != spell.Family { + continue + } + + // Do not let a weaker spell replace a stronger one. + if existingSpell.Level > spell.Level { + return false + } + + // Same spell stays in place so ApplyStatEffect refreshes it. + if existingSpell.ID == spell.ID { + return true + } + + // Stronger incoming spell replaces the weaker family member. + player.ActiveStatEffects = append( + player.ActiveStatEffects[:i], + player.ActiveStatEffects[i+1:]..., + ) + } + + return true +} diff --git a/engine/internal/engine/treasure.go b/engine/internal/engine/treasure.go index 1ea4b56..39d381d 100644 --- a/engine/internal/engine/treasure.go +++ b/engine/internal/engine/treasure.go @@ -93,12 +93,33 @@ func (e *GameEngine) generateTreasure(roomNum int, treasureLevel int) []string { } case roll < 55: - // Locked container — for rogues to practice lockpicking (available at all levels) + // Locked container — for rogues to practice lockpicking. if item := e.randomChestDrop(treasureLevel); item != nil { ref := len(room.Items) item.Ref = ref + item.State = "LOCKED" + room.Items = append(room.Items, *item) - msgs = append(msgs, "A small locked chest lies among the remains.") + + e.generateChestContents( + room, + ref, + treasureLevel, + ) + + if def := e.items[item.Archetype]; def != nil { + name := e.formatItemName( + def, + item.Adj1, + item.Adj2, + item.Adj3, + ) + + msgs = append( + msgs, + fmt.Sprintf("%s lies among the remains.", capArticle(name)), + ) + } } case roll < 75: @@ -286,7 +307,9 @@ func (e *GameEngine) randomChestDrop(treasureLevel int) *gameworld.RoomItem { // Chance for trap (scales with treasure level: 10% at level 1, up to 50% at high levels) trapChance := 10 + treasureLevel/2 - if trapChance > 50 { trapChance = 50 } + if trapChance > 50 { + trapChance = 50 + } if rand.Intn(100) < trapChance { trapTypes := []int{1, 2, 4, 5} // needle, gas, blades, moderate needle if treasureLevel >= 30 { @@ -301,6 +324,103 @@ func (e *GameEngine) randomChestDrop(treasureLevel int) *gameworld.RoomItem { return item } +func (e *GameEngine) generateChestContents(room *gameworld.Room, chestRef int, treasureLevel int) { + addItem := func(item *gameworld.RoomItem) { + if item == nil { + return + } + + item.Ref = chestRef + item.IsPut = true + item.PutIn = chestRef + + room.Items = append(room.Items, *item) + } + + addMoney := func(denomination int, amount int) { + if amount <= 0 { + return + } + + moneyArch := e.findBaseMoneyArchetype(denomination) + if moneyArch == 0 { + return + } + + room.Items = append( + room.Items, + gameworld.RoomItem{ + Ref: chestRef, + Archetype: moneyArch, + Val1: amount, + IsPut: true, + PutIn: chestRef, + }, + ) + } + + // Always include a better coin reward than loose treasure. + copperBase := treasureLevel * 15 + + if copperBase < 1 { + copperBase = 1 + } + + coins := copperBase + rand.Intn(copperBase+1) + + gold := coins / 100 + remaining := coins % 100 + + silver := remaining / 10 + copper := remaining % 10 + + // <-- Put these three lines HERE + addMoney(MoneyGold, gold) + addMoney(MoneySilver, silver) + addMoney(MoneyCopper, copper) + + // Chests always contain at least one useful item. + switch rand.Intn(3) { + case 0: + addItem(e.randomWeaponDrop(treasureLevel)) + case 1: + addItem(e.randomArmorDrop(treasureLevel)) + case 2: + addItem(e.randomScrollDrop(treasureLevel)) + } + + // Better chests have a chance at another useful item. + if treasureLevel >= 20 && rand.Intn(100) < 50 { + switch rand.Intn(3) { + case 0: + addItem(e.randomWeaponDrop(treasureLevel)) + case 1: + addItem(e.randomArmorDrop(treasureLevel)) + case 2: + addItem(e.randomScrollDrop(treasureLevel)) + } + } +} + +func (e *GameEngine) findBaseMoneyArchetype(denomination int) int { + for num, def := range e.items { + if def.Type != "MONEY" { + continue + } + + // Base currency only; regional currencies use PARAMETER2 > 0. + if def.Parameter2 != 0 { + continue + } + + if def.Parameter1 == denomination { + return num + } + } + + return 0 +} + func joinParts(parts []string) string { if len(parts) == 0 { return "" @@ -310,3 +430,9 @@ func joinParts(parts []string) string { } return fmt.Sprintf("%s and %s", parts[0], parts[len(parts)-1]) } + +const ( + MoneyGold = 1 + MoneySilver = 2 + MoneyCopper = 3 +) diff --git a/engine/internal/scriptparser/parser.go b/engine/internal/scriptparser/parser.go index a5b6c5b..6ecedd3 100644 --- a/engine/internal/scriptparser/parser.go +++ b/engine/internal/scriptparser/parser.go @@ -13,23 +13,23 @@ import ( // ParseResult holds all data parsed from script files. type ParseResult struct { - Rooms []gameworld.Room - Items []gameworld.ItemDef - Monsters []gameworld.MonsterDef - Nouns []gameworld.NounDef - Adjectives []gameworld.AdjDef - MonsterAdjs []gameworld.MonsterAdjDef - Variables []gameworld.Variable - Regions []gameworld.Region + Rooms []gameworld.Room + Items []gameworld.ItemDef + Monsters []gameworld.MonsterDef + Nouns []gameworld.NounDef + Adjectives []gameworld.AdjDef + MonsterAdjs []gameworld.MonsterAdjDef + Variables []gameworld.Variable + Regions []gameworld.Region MonsterLists []gameworld.MonsterList SeasonalMonsterLists map[string][]gameworld.MonsterList // "PSCRIPT" -> spring MLISTs, etc. SeasonalRooms map[string][]gameworld.Room // seasonal room description overrides - CEvents []gameworld.CEvent - MoneyDefs []gameworld.MoneyDef - ForageDefs []gameworld.ForageDef - MineDefs []gameworld.MineDef - StartRoom int - BumpRoom int + CEvents []gameworld.CEvent + MoneyDefs []gameworld.MoneyDef + ForageDefs []gameworld.ForageDef + MineDefs []gameworld.MineDef + StartRoom int + BumpRoom int } // ParseConfig reads LEGENDS.CFG and loads all referenced script files. @@ -426,7 +426,7 @@ func (p *fileParser) parseRoom(fields []string) { room.Region, _ = strconv.Atoi(fields[1]) } case "FORGE", "LOOM", "MINEA", "MINEB", "MINEC", - "BUY_ARMOR", "BUY_SKINS", "BUY_JEWELRY", "SUBMERGED", + "BUY_ARMOR", "BUY_SKINS", "BUY_JEWELRY", "SUBMERGED", "BANK", "MOVEMENT_ASTRAL": room.Modifiers = append(room.Modifiers, cmd) case "IFVERB", "IFPREVERB", "IFVERB2", "IFPREVERB2", @@ -571,10 +571,11 @@ func (p *fileParser) parseItem(fields []string) { "RIFLE", "SCROLL", "SHIELD", "SLASH_WEAPON", "STABTHROWN", "THROWN_WEAPON", "TRAP", "TWOHAND_WEAPON", "ORE": item.Type = cmd + //Shields are now offhand items, not worn armor. The engine will handle this automatically. // Shields default to WORN_ARMOR if no explicit worn slot - if cmd == "SHIELD" && item.WornSlot == "" { - item.WornSlot = "WORN_ARMOR" - } + //if cmd == "SHIELD" && item.WornSlot == "" { + // item.WornSlot = "WORN_ARMOR" + //} // Worn slots case "WORN_AROUND", "WORN_BACK", "WORN_BODY", "WORN_DON", "WORN_EAR", "WORN_FEET1", "WORN_FEET2", "WORN_HAIR", @@ -683,21 +684,37 @@ func (p *fileParser) parseMonster(fields []string) { mon.Gender, _ = strconv.Atoi(fields[1]) } case "ALIGNMENT": - if len(fields) >= 2 { mon.Alignment, _ = strconv.Atoi(fields[1]) } + if len(fields) >= 2 { + mon.Alignment, _ = strconv.Atoi(fields[1]) + } case "RESIST": - if len(fields) >= 2 { mon.MagicResist, _ = strconv.Atoi(fields[1]) } + if len(fields) >= 2 { + mon.MagicResist, _ = strconv.Atoi(fields[1]) + } case "MANA": - if len(fields) >= 2 { mon.Mana, _ = strconv.Atoi(fields[1]) } + if len(fields) >= 2 { + mon.Mana, _ = strconv.Atoi(fields[1]) + } case "SPELLUSE": - if len(fields) >= 2 { mon.SpellUse, _ = strconv.Atoi(fields[1]) } + if len(fields) >= 2 { + mon.SpellUse, _ = strconv.Atoi(fields[1]) + } case "SPELLSKILL": - if len(fields) >= 2 { mon.SpellSkill, _ = strconv.Atoi(fields[1]) } + if len(fields) >= 2 { + mon.SpellSkill, _ = strconv.Atoi(fields[1]) + } case "CASTLEVEL": - if len(fields) >= 2 { mon.CastLevel, _ = strconv.Atoi(fields[1]) } + if len(fields) >= 2 { + mon.CastLevel, _ = strconv.Atoi(fields[1]) + } case "HIDESKILL": - if len(fields) >= 2 { mon.HideSkill, _ = strconv.Atoi(fields[1]) } + if len(fields) >= 2 { + mon.HideSkill, _ = strconv.Atoi(fields[1]) + } case "GUARD": - if len(fields) >= 2 { mon.GuardItem, _ = strconv.Atoi(fields[1]) } + if len(fields) >= 2 { + mon.GuardItem, _ = strconv.Atoi(fields[1]) + } case "STEALABLE": mon.Stealable = true case "ETERNAL": @@ -715,15 +732,25 @@ func (p *fileParser) parseMonster(fields []string) { mon.DiseaseLevel, _ = strconv.Atoi(fields[2]) } case "SKINADJ": - if len(fields) >= 2 { mon.SkinAdj, _ = strconv.Atoi(fields[1]) } + if len(fields) >= 2 { + mon.SkinAdj, _ = strconv.Atoi(fields[1]) + } case "SKINITEM": if len(fields) >= 2 { mon.SkinItem, _ = strconv.Atoi(fields[1]) sd := gameworld.SkinDrop{Archetype: mon.SkinItem} - if len(fields) >= 3 { sd.Probability, _ = strconv.Atoi(fields[2]) } - if len(fields) >= 4 { sd.Value, _ = strconv.Atoi(fields[3]) } - if len(fields) >= 5 { sd.Magic, _ = strconv.Atoi(fields[4]) } - if sd.Probability <= 0 { sd.Probability = 10 } + if len(fields) >= 3 { + sd.Probability, _ = strconv.Atoi(fields[2]) + } + if len(fields) >= 4 { + sd.Value, _ = strconv.Atoi(fields[3]) + } + if len(fields) >= 5 { + sd.Magic, _ = strconv.Atoi(fields[4]) + } + if sd.Probability <= 0 { + sd.Probability = 10 + } mon.SkinItems = append(mon.SkinItems, sd) } case "IMMUNITY": @@ -789,15 +816,25 @@ func (p *fileParser) parseMonster(fields []string) { mon.Spells = append(mon.Spells, spellID) } case "PSI": - if len(fields) >= 2 { mon.Psi, _ = strconv.Atoi(fields[1]) } + if len(fields) >= 2 { + mon.Psi, _ = strconv.Atoi(fields[1]) + } case "PSIUSE": - if len(fields) >= 2 { mon.PsiUse, _ = strconv.Atoi(fields[1]) } + if len(fields) >= 2 { + mon.PsiUse, _ = strconv.Atoi(fields[1]) + } case "PSISKILL": - if len(fields) >= 2 { mon.PsiSkill, _ = strconv.Atoi(fields[1]) } + if len(fields) >= 2 { + mon.PsiSkill, _ = strconv.Atoi(fields[1]) + } case "PSIRESIST": - if len(fields) >= 2 { mon.PsiResist, _ = strconv.Atoi(fields[1]) } + if len(fields) >= 2 { + mon.PsiResist, _ = strconv.Atoi(fields[1]) + } case "PSILEVEL": - if len(fields) >= 2 { mon.PsiLevel, _ = strconv.Atoi(fields[1]) } + if len(fields) >= 2 { + mon.PsiLevel, _ = strconv.Atoi(fields[1]) + } case "DISCIPLINE": if len(fields) >= 2 { disc, _ := strconv.Atoi(fields[1]) @@ -807,7 +844,9 @@ func (p *fileParser) parseMonster(fields []string) { "TEXI", "TEXL", "TEXM", "TEXQ", "TEXR", "TEXTS", "TEXS", "TEXV", "TEXZ", "TEX1", "TEX2", "TEX3", "TEX4": if len(fields) >= 2 { - if mon.TextOverrides == nil { mon.TextOverrides = make(map[string]string) } + if mon.TextOverrides == nil { + mon.TextOverrides = make(map[string]string) + } mon.TextOverrides[cmd] = strings.Join(fields[1:], " ") } case "*DESCRIPTION_START": @@ -826,9 +865,11 @@ func (p *fileParser) parseMonster(fields []string) { } // parseItemDescArgs parses the args after "*DESCRIPTION_START ITEM", handling: -// EXAM 0, READ 5, IN 3, ON 2, UNDER 1, BEHIND 0 (verb ref) -// 0 EXAM, 1 IN, 1 READ (ref verb - reversed) -// 4 (bare ref - defaults to EXAMINE) +// +// EXAM 0, READ 5, IN 3, ON 2, UNDER 1, BEHIND 0 (verb ref) +// 0 EXAM, 1 IN, 1 READ (ref verb - reversed) +// 4 (bare ref - defaults to EXAMINE) +// // Returns normalized (action, ref) pair. func (p *fileParser) parseItemDescArgs(args []string) (string, string) { normalizeVerb := func(v string) string { @@ -1077,4 +1118,3 @@ func (p *fileParser) parseRegion(fields []string) { region.MineAdj, _ = strconv.Atoi(val) } } - diff --git a/frontend/src/components/MainMenu.tsx b/frontend/src/components/MainMenu.tsx index ba79439..8b45b00 100644 --- a/frontend/src/components/MainMenu.tsx +++ b/frontend/src/components/MainMenu.tsx @@ -437,15 +437,22 @@ export default function MainMenu({ onNewCharacter, onSelectCharacter, onVersionN ) : null} - {/* New character button — only when backend is available and email verified */} - {!loading && backendUp && user?.account?.emailVerified !== false && ( - - )} + {/* New character button — max 3 characters */} + {!loading && backendUp && user?.account?.emailVerified !== false && ( + + )} {players.length === 0 && !loading && backendUp && (

diff --git a/frontend/src/components/Manual.tsx b/frontend/src/components/Manual.tsx index 7ed0f11..015877b 100644 --- a/frontend/src/components/Manual.tsx +++ b/frontend/src/components/Manual.tsx @@ -1364,6 +1364,7 @@ Obvious exits: north, south, east. + diff --git a/original/GM Pages/advanced.html b/original/GM Pages/advanced.html index 89e1b27..d4ccbb9 100644 --- a/original/GM Pages/advanced.html +++ b/original/GM Pages/advanced.html @@ -13,11 +13,11 @@ Here are some of the more in depth features of the scripting language.

-Document on Foraging and Mining
-Monster Scripting
+Document on Foraging and Mining
+Monster Scripting
Region Scripting
-Technologist Information
-Variables for Items
+Technologist Information
+Variables for Items


Previous | diff --git a/original/scripts-older/FAYD.SCR b/original/scripts-older/FAYD.SCR index 03650cc..341e4b1 100644 --- a/original/scripts-older/FAYD.SCR +++ b/original/scripts-older/FAYD.SCR @@ -1925,6 +1925,14 @@ EXIT SE 291 EXIT SW 290 EXIT ABOVE 2152 ITEM 0 68 ADJ1=25 ADJ2=192 VAL2=2605 +IFVERB TOUCH 0 + RANDOM DUMMY1 10 + ADD DUMMY1 1 + STRCVT 0 DUMMY1 + ECHO PLAYER A bolt of lightning leaps from the black gate and strikes you for %0 damage! + ECHO OTHERS A bolt of lightning leaps from the black gate and strikes %N! + DAMAGEPLR ELECTRIC DUMMY1 +ENDIF ; NUMBER 290 NAME Poor Quarter diff --git a/original/scripts-older/FAYDFALL.SCR b/original/scripts-older/FAYDFALL.SCR index fca626a..98f7aba 100644 --- a/original/scripts-older/FAYDFALL.SCR +++ b/original/scripts-older/FAYDFALL.SCR @@ -1772,7 +1772,14 @@ EXIT SW 290 EXIT ABOVE 2152 ITEM 0 68 ADJ1=25 ADJ2=192 VAL2=2605 ITEM 9 693 ADJ1=10 - +IFVERB TOUCH 0 + RANDOM DUMMY1 10 + ADD DUMMY1 1 + STRCVT 0 DUMMY1 + ECHO PLAYER A bolt of lightning leaps from the black gate and strikes you for %0 damage! + ECHO OTHERS A bolt of lightning leaps from the black gate and strikes %N! + DAMAGEPLR ELECTRIC DUMMY1 +ENDIF ; NUMBER 290 NAME Poor Quarter diff --git a/original/scripts-older/ITEMWEAP.SCR b/original/scripts-older/ITEMWEAP.SCR index a939a60..638274e 100644 --- a/original/scripts-older/ITEMWEAP.SCR +++ b/original/scripts-older/ITEMWEAP.SCR @@ -2849,7 +2849,7 @@ IFPREVERB WIELD -1 ECHO PLAYER You are not skilled enough in the way of the claw to wield these. ENDIF IFVAR SKILL4 > 9 - ECHO PLAYER [You may now specialize in your natural claws by typing 'SPEC CLAWS'] + ECHO PLAYER You strap on the claws. [You may now specialize in your natural claws by typing 'SPEC CLAWS'] IFVAR SKILL13 < 10 IFVAR SKILL13 = 0 EQUAL INTNUM96 999 @@ -2862,6 +2862,7 @@ IFPREVERB WIELD -1 ENDIF ENDIF IFPREVERB UNWIELD -1 + ECHO PLAYER You remove the claws and the power of the wolf fades from you. IFVAR INTNUM96 ! 0 IFVAR INTNUM96 = 999 EQUAL SKILL13 0 diff --git a/original/scripts/CHASSO.SCR b/original/scripts/CHASSO.SCR index 83d3cf7..f75ac4c 100644 --- a/original/scripts/CHASSO.SCR +++ b/original/scripts/CHASSO.SCR @@ -2093,8 +2093,7 @@ ENDIF ; NUMBER 3639 NAME House of the Elder Wolf -CALL 33 - +CALL 33 ITEM 9 693 *DESCRIPTION_START This simple chamber is sparsely decorated, with a small woven mat and two incense burners to either side. On it, cross-legged, an aging wolfling rests, still fit despite his years. A small sign has been posted by the gate, and a set of strap-on claws hang from a hook nearby. diff --git a/original/scripts/DEJOBAAN.SC1 b/original/scripts/DEJOBAAN.SC1 index 7864962..8a9087c 100644 --- a/original/scripts/DEJOBAAN.SC1 +++ b/original/scripts/DEJOBAAN.SC1 @@ -276,6 +276,16 @@ SUBSTANCE INSUBSTANTIAL FOOD PARAMETER1 10 REAGENT + +IFVERB EAT -1 + IFVAR ITEMADJ3 = 531 + RANDOM DUMMY1 10 + ADD DUMMY1 1 + ECHO PLAYER The root tastes horrible but a warm sensation spreads through your body. + HEALPLR DUMMY1 You recover %D body points + ENDIF +ENDIF + ifpreverb cast -1 ifvar itemadj3 = 542 ifvar manapoints > 26 diff --git a/original/scripts/FAYD.SCR b/original/scripts/FAYD.SCR index 03650cc..4be64c2 100644 --- a/original/scripts/FAYD.SCR +++ b/original/scripts/FAYD.SCR @@ -393,7 +393,7 @@ There is a manuscript detailing the items for sale by the Armorers Guild here. *DESCRIPTION_START ITEM READ 1 Weapons Price Weapons Price ---- ----- ---- ----- -Axe 3 Crowns Long Sword 27 Crowns +Axe1 3 Crowns Long Sword 27 Crowns Battleaxe 12 Crowns Mace 10 Crowns Dagger 2 Crowns Morning Star 15 Crowns Glaive 17 Crowns Pike 25 Crowns @@ -647,6 +647,7 @@ EXIT NE 228 EXIT W 230 ITEM 0 213 VAL2=283 ITEM 1 1416 ADJ1=355 + EXIT ABOVE 2152 ; NUMBER 230 @@ -1818,7 +1819,7 @@ CALL 33 The homeless huddle around a metal can of burning trash. A lucky few with jackets hang back from the flames, proud of their greater solvency. At the top of a stairway, some ragged men of questionable intent engage in whispered conversation. Young children cling to their mothers; hiding behind dirty skirts offering only symbolic protection. *DESCRIPTION_END OUTDOOR_OTHER -FIXED_LIGHT +DAY_LIGHT EXIT SE 288 EXIT SW 285 EXIT N 283 @@ -1918,13 +1919,20 @@ MONSTER_GROUP 2 DAY_LIGHT CALL 33 *DESCRIPTION_START -Boards have been nailed across the door of the old Temple of Kaliban, preventing entry. Small particles of wood and sawdust lie at the base of a sawed-off stump. There is a small sign tacked up across the boards. +Boards1 have been nailed across the door of the old Temple of Kaliban, preventing entry. Small particles of wood and sawdust lie at the base of a sawed-off stump. There is a small sign tacked up across the boards. *DESCRIPTION_END EXIT W 288 EXIT SE 291 EXIT SW 290 EXIT ABOVE 2152 ITEM 0 68 ADJ1=25 ADJ2=192 VAL2=2605 + +IFTOUCH 0 + ECHO OTHERS %N touches the black metal gate... + ECHO PLAYER You touch the black metal gate... + DAMAGE ELECTRIC 15 %N is struck by an arc of lightning from the gate! + CLEARVERB +ENDIF ; NUMBER 290 NAME Poor Quarter diff --git a/original/scripts/FAYDFALL.SCR b/original/scripts/FAYDFALL.SCR index fca626a..d82f9b9 100644 --- a/original/scripts/FAYDFALL.SCR +++ b/original/scripts/FAYDFALL.SCR @@ -594,7 +594,9 @@ EXIT W 243 EXIT E 230 EXIT ABOVE 2152 ITEM 9 693 ADJ1=10 - +IFVERB LISTEN -1 + ECHO PLAYER You can hear an acolyte of Shemri and a Priestess of Amilor fighting like a badly matched husband and wife. +ENDIF ; NUMBER 241 NAME Church Street @@ -1764,7 +1766,7 @@ MONSTER_GROUP 2 DAY_LIGHT CALL 33 *DESCRIPTION_START -Boards have been nailed across the door of the old Temple of Kaliban, preventing entry. Small particles of wood and sawdust lie at the base of a sawed-off stump. There is a small sign tacked up across the boards. +Boards3 have been nailed across the door of the old Temple of Kaliban, preventing entry. Small particles of wood and sawdust lie at the base of a sawed-off stump. There is a small sign tacked up across the boards. *DESCRIPTION_END EXIT W 288 EXIT SE 291 @@ -1772,6 +1774,12 @@ EXIT SW 290 EXIT ABOVE 2152 ITEM 0 68 ADJ1=25 ADJ2=192 VAL2=2605 ITEM 9 693 ADJ1=10 +IFTOUCH 0 + ECHO OTHERS %N touches the black metal gate... + ECHO PLAYER You touch the black metal gate... + DAMAGE ELECTRIC 15 %N is struck by an arc of lightning from the gate! + CLEARVERB +ENDIF ; NUMBER 290 diff --git a/original/scripts/FAYDINDR.SCR b/original/scripts/FAYDINDR.SCR index ed86020..48f1804 100644 --- a/original/scripts/FAYDINDR.SCR +++ b/original/scripts/FAYDINDR.SCR @@ -3103,13 +3103,14 @@ You see a working scale. Item Price ---- ----- Lantern 20 crowns -Torch 1 crown +Torch 1 crowns Sack 4 crowns Backpack 5 crowns Sheath 2 crowns Pick-axe 20 crowns Shovel 20 crowns Miner's Hammer 10 crowns + *DESCRIPTION_END STOREITEM 89 -1 2000 STOREITEM 466 -1 100 diff --git a/original/scripts/FAYDSPRI.SCR b/original/scripts/FAYDSPRI.SCR index fe2493b..6a3cd0f 100644 --- a/original/scripts/FAYDSPRI.SCR +++ b/original/scripts/FAYDSPRI.SCR @@ -557,6 +557,7 @@ EXIT NE 228 EXIT W 230 ITEM 0 213 VAL2=283 ITEM 1 1416 ADJ1=355 + EXIT ABOVE 2152 ; NUMBER 230 @@ -1643,7 +1644,7 @@ ITEM 9 693 ADJ1=10 The homeless huddle around a metal can of burning trash. A lucky few with jackets hang back from the flames, proud of their greater solvency. At the top of a stairway, some ragged men of questionable intent engage in whispered conversation. Young children cling to their mothers; hiding behind dirty skirts offering only symbolic protection. *DESCRIPTION_END OUTDOOR_OTHER -FIXED_LIGHT +DAY_LIGHT EXIT SE 288 EXIT SW 285 EXIT N 283 @@ -1760,6 +1761,14 @@ EXIT SE 291 EXIT SW 290 EXIT ABOVE 2152 ITEM 0 68 ADJ1=25 ADJ2=192 VAL2=2605 +IFVERB TOUCH 0 + RANDOM DUMMY1 10 + ADD DUMMY1 1 + STRCVT 0 DUMMY1 + ECHO PLAYER A bolt of lightning leaps from the black gate and strikes you for %0 damage! + ECHO OTHERS A bolt of lightning leaps from the black gate and strikes %N! + DAMAGEPLR ELECTRIC DUMMY1 +ENDIF ; NUMBER 290 NAME Poor Quarter diff --git a/original/scripts/FAYDSUM.SCR b/original/scripts/FAYDSUM.SCR index 54c5c0a..46e44a5 100644 --- a/original/scripts/FAYDSUM.SCR +++ b/original/scripts/FAYDSUM.SCR @@ -1850,7 +1850,7 @@ MONSTER_GROUP 2 DAY_LIGHT CALL 33 *DESCRIPTION_START -Boards have been nailed across the door of the old Temple of Kaliban, preventing entry. Small particles of wood and sawdust lie at the base of a sawed-off stump. There is a small sign tacked up across the boards. +Boards 4have been nailed across the door of the old Temple of Kaliban, preventing entry. Small particles of wood and sawdust lie at the base of a sawed-off stump. There is a small sign tacked up across the boards. *DESCRIPTION_END EXIT W 288 EXIT SE 291 @@ -1859,6 +1859,13 @@ EXIT ABOVE 2152 ITEM 9 693 ADJ1=10 ITEM 0 68 ADJ1=25 ADJ2=192 VAL2=2605 + +IFTOUCH 0 + ECHO OTHERS %N touches the black metal gate... + ECHO PLAYER You touch the black metal gate... + DAMAGE ELECTRIC 15 %N is struck by an arc of lightning from the gate! + CLEARVERB +ENDIF ; NUMBER 290 NAME Poor Quarter @@ -1909,6 +1916,7 @@ EXIT SW 294 EXIT ABOVE 2152 ITEM 9 693 ADJ1=10 + ; NUMBER 294 NAME Poor Quarter diff --git a/original/scripts/FAYDWIN.SCR b/original/scripts/FAYDWIN.SCR index 8b3e0bc..bcc24c3 100644 --- a/original/scripts/FAYDWIN.SCR +++ b/original/scripts/FAYDWIN.SCR @@ -1788,6 +1788,14 @@ EXIT SE 291 EXIT SW 290 EXIT ABOVE 2152 ITEM 0 68 ADJ1=25 ADJ2=192 VAL2=2605 +IFVERB TOUCH 0 + RANDOM DUMMY1 10 + ADD DUMMY1 1 + STRCVT 0 DUMMY1 + ECHO PLAYER A bolt of lightning leaps from the black gate and strikes you for %0 damage! + ECHO OTHERS A bolt of lightning leaps from the black gate and strikes %N! + DAMAGEPLR ELECTRIC DUMMY1 +ENDIF ; NUMBER 290 NAME Poor Quarter diff --git a/original/scripts/FORESTER.SCR b/original/scripts/FORESTER.SCR index 3658728..1c5485c 100644 --- a/original/scripts/FORESTER.SCR +++ b/original/scripts/FORESTER.SCR @@ -50,6 +50,7 @@ IFPREVERB2 PUT 0 IFVAR VOICES >= 3 REMOVEITEM -1 ECHO ALL As %n places the item in the knothole, there is a bright flash of light and it disappears!%c%cA great weight lifts from your heart. + SPELL 515 100 ENDIF IFVAR VOICES < 3 ECHO ALL The tree does not have enough power at the moment. @@ -127,7 +128,7 @@ EXIT W 564 EXIT N 1470 ;-------path------ ITEM 0 74 VAL2=569 -IFVERB GO 0 +IFPVERB GO 0 IFVAR ALIGN <= -1 ECHO PLAYER The forces of nature align to force you away from the grove. ECHO OTHERS %N tried to enter the grove but is thrown back by some unseen source. diff --git a/original/scripts/ITEM1.SCR b/original/scripts/ITEM1.SCR index 92a962a..0d8b28c 100644 --- a/original/scripts/ITEM1.SCR +++ b/original/scripts/ITEM1.SCR @@ -8140,7 +8140,7 @@ ENDIF INUMBER 90 NAME 126 WEIGHT 1 -VOLUME 0 +VOLUME 1 CRAFTABLE ENCRUSTABLE PARAMETER2 18 @@ -9626,6 +9626,15 @@ SUBSTANCE INSUBSTANTIAL FOOD PARAMETER1 10 REAGENT + +IFVERB EAT -1 + IFVAR ITEMADJ3 = 531 + RANDOM DUMMY1 10 + ADD DUMMY1 1 + ECHO PLAYER The root tastes horrible but a warm sensation spreads through your body. + HEALPLR DUMMY1 You recover %D body points + ENDIF +ENDIF ; ; Nightshade INUMBER 587 diff --git a/original/scripts/ITEMWEAP.SCR b/original/scripts/ITEMWEAP.SCR index a939a60..9524f99 100644 --- a/original/scripts/ITEMWEAP.SCR +++ b/original/scripts/ITEMWEAP.SCR @@ -2824,20 +2824,27 @@ PARAMETER1 1 ARTICLE SOME SUBSTANCE INDESTRUCTABLE IFPREVERB GET -1 + IFVAR SKILL4 < 10 CLEARVERB ECHO PLAYER You are not skilled enough in the way of the claw to wield these. ENDIF - IFVAR SKILL4 > 9 - ECHO PLAYER [You may now specialize in your natural claws by typing 'SPEC CLAWS'] - IFVAR SKILL13 < 10 - IFVAR SKILL13 = 0 - EQUAL INTNUM96 999 - ENDIF - IFVAR SKILL13 ! 0 - EQUAL INTNUM96 SKILL13 + + IFCARRY 651 -1 + CLEARVERB + ECHO PLAYER You already have the claws. + ELSE + IFVAR SKILL4 > 9 + ECHO PLAYER [You may now specialize in your natural claws by typing 'SPEC CLAWS'] + IFVAR SKILL13 < 10 + IFVAR SKILL13 = 0 + EQUAL INTNUM96 999 + ENDIF + IFVAR SKILL13 ! 0 + EQUAL INTNUM96 SKILL13 + ENDIF + EQUAL SKILL13 10 ENDIF - EQUAL SKILL13 10 ENDIF ENDIF AFFECT 3639 @@ -2849,7 +2856,7 @@ IFPREVERB WIELD -1 ECHO PLAYER You are not skilled enough in the way of the claw to wield these. ENDIF IFVAR SKILL4 > 9 - ECHO PLAYER [You may now specialize in your natural claws by typing 'SPEC CLAWS'] + ECHO PLAYER [You may now specialize in your natural claws by typing 'SPEC CLAWS'!] IFVAR SKILL13 < 10 IFVAR SKILL13 = 0 EQUAL INTNUM96 999 @@ -2862,6 +2869,7 @@ IFPREVERB WIELD -1 ENDIF ENDIF IFPREVERB UNWIELD -1 + ECHO PLAYER You remove the claws and the power of the wolf fades from you. IFVAR INTNUM96 ! 0 IFVAR INTNUM96 = 999 EQUAL SKILL13 0 diff --git a/original/scripts/OUTDOOR.SCR b/original/scripts/OUTDOOR.SCR index 120cbf8..8b45870 100644 --- a/original/scripts/OUTDOOR.SCR +++ b/original/scripts/OUTDOOR.SCR @@ -1681,7 +1681,7 @@ ENDIF IFVERB LISTEN -1 ECHO PLAYER You hear chirps, whistles and soft coos. ENDIF -IFVERB GO 0 +IFPREVERB GO 0 IFVAR ALIGN <= -1 ECHO PLAYER The forces of nature align to force you away from the grove. ECHO OTHERS %N tried to enter the grove but is thrown back by some unseen source.