eq2go/internal/housing/housing.go

733 lines
21 KiB
Go

package housing
import (
"context"
"eq2emu/internal/database"
"eq2emu/internal/packets"
"fmt"
"sync"
"time"
)
// House represents a purchasable house type/zone
type House struct {
mu sync.RWMutex
ID int32
Name string
CostCoins int64
CostStatus int64
UpkeepCoins int64
UpkeepStatus int64
VaultSlots int8
Alignment int8
GuildLevel int8
ZoneID int32
ExitZoneID int32
ExitX float32
ExitY float32
ExitZ float32
ExitHeading float32
}
// CharacterHouse represents a house owned by a character
type CharacterHouse struct {
mu sync.RWMutex
UniqueID int64
CharacterID int32
HouseID int32
InstanceID int32
UpkeepDue time.Time
EscrowCoins int64
EscrowStatus int64
Status int8
Settings CharacterHouseSettings
AccessList map[int32]HouseAccess
Items []HouseItem
History []HouseHistory
}
// CharacterHouseSettings represents house configuration
type CharacterHouseSettings struct {
HouseName string
VisitPermission int8
PublicNote string
PrivateNote string
AllowFriends bool
AllowGuild bool
RequireApproval bool
ShowOnDirectory bool
AllowDecoration bool
}
// HouseAccess represents access permissions for a character
type HouseAccess struct {
CharacterID int32
PlayerName string
AccessLevel int8
Permissions int32
GrantedBy int32
GrantedDate time.Time
ExpiresDate time.Time
Notes string
}
// HouseItem represents an item placed in a house
type HouseItem struct {
ID int64
ItemID int32
CharacterID int32
X float32
Y float32
Z float32
Heading float32
PitchX float32
PitchY float32
RollX float32
RollY float32
PlacedDate time.Time
Quantity int32
Condition int8
}
// HouseHistory represents a transaction history entry
type HouseHistory struct {
Timestamp time.Time
Amount int64
Status int64
Reason string
Name string
CharacterID int32
Type int
}
// HousingManager manages the housing system
type HousingManager struct {
mu sync.RWMutex
db *database.Database
houses map[int32]*House // Available house types
characterHouses map[int64]*CharacterHouse // All character houses by unique ID
characterIndex map[int32][]*CharacterHouse // Character houses by character ID
logger Logger
config HousingConfig
}
// Logger interface for housing system logging
type Logger interface {
LogInfo(system, format string, args ...interface{})
LogError(system, format string, args ...interface{})
LogDebug(system, format string, args ...interface{})
LogWarning(system, format string, args ...interface{})
}
// PlayerManager interface for player operations
type PlayerManager interface {
CanPlayerAffordHouse(characterID int32, coinCost, statusCost int64) (bool, error)
DeductPlayerCoins(characterID int32, amount int64) error
DeductPlayerStatus(characterID int32, amount int64) error
GetPlayerAlignment(characterID int32) (int8, error)
GetPlayerGuildLevel(characterID int32) (int8, error)
}
// HousingConfig contains housing system configuration
type HousingConfig struct {
EnableUpkeep bool
EnableForeclosure bool
UpkeepGracePeriod int32
MaxHousesPerPlayer int
EnableStatistics bool
}
// NewHousingManager creates a new housing manager
func NewHousingManager(db *database.Database, logger Logger, config HousingConfig) *HousingManager {
return &HousingManager{
db: db,
houses: make(map[int32]*House),
characterHouses: make(map[int64]*CharacterHouse),
characterIndex: make(map[int32][]*CharacterHouse),
logger: logger,
config: config,
}
}
// Initialize loads house data and starts background processes
func (hm *HousingManager) Initialize(ctx context.Context) error {
hm.mu.Lock()
defer hm.mu.Unlock()
// Load available house types from database
houseData, err := hm.loadHousesFromDB(ctx)
if err != nil {
return fmt.Errorf("failed to load houses: %w", err)
}
for _, house := range houseData {
hm.houses[house.ID] = house
}
hm.logger.LogInfo("housing", "Loaded %d house types", len(hm.houses))
// Start upkeep processing if enabled
if hm.config.EnableUpkeep {
go hm.processUpkeepLoop(ctx)
}
return nil
}
// GetHouse returns a house type by ID
func (hm *HousingManager) GetHouse(houseID int32) (*House, bool) {
hm.mu.RLock()
defer hm.mu.RUnlock()
house, exists := hm.houses[houseID]
return house, exists
}
// GetAvailableHouses returns all available house types
func (hm *HousingManager) GetAvailableHouses() []*House {
hm.mu.RLock()
defer hm.mu.RUnlock()
houses := make([]*House, 0, len(hm.houses))
for _, house := range hm.houses {
houses = append(houses, house)
}
return houses
}
// GetCharacterHouses returns all houses owned by a character
func (hm *HousingManager) GetCharacterHouses(characterID int32) ([]*CharacterHouse, error) {
hm.mu.RLock()
houses, exists := hm.characterIndex[characterID]
hm.mu.RUnlock()
if !exists {
// If no database, return empty list
if hm.db == nil {
return []*CharacterHouse{}, nil
}
// Load from database
houses, err := hm.loadCharacterHousesFromDB(context.Background(), characterID)
if err != nil {
return nil, fmt.Errorf("failed to load character houses: %w", err)
}
hm.mu.Lock()
hm.characterIndex[characterID] = houses
for _, house := range houses {
hm.characterHouses[house.UniqueID] = house
}
hm.mu.Unlock()
return houses, nil
}
return houses, nil
}
// PurchaseHouse handles house purchase requests
func (hm *HousingManager) PurchaseHouse(ctx context.Context, characterID int32, houseID int32, playerManager PlayerManager) (*CharacterHouse, error) {
// Get house type
house, exists := hm.GetHouse(houseID)
if !exists {
return nil, fmt.Errorf("house type %d not found", houseID)
}
// Check if player can afford it
canAfford, err := playerManager.CanPlayerAffordHouse(characterID, house.CostCoins, house.CostStatus)
if err != nil {
return nil, fmt.Errorf("failed to check affordability: %w", err)
}
if !canAfford {
return nil, fmt.Errorf("insufficient funds")
}
// Check alignment requirement
if house.Alignment != AlignmentAny {
alignment, err := playerManager.GetPlayerAlignment(characterID)
if err != nil {
return nil, fmt.Errorf("failed to get player alignment: %w", err)
}
if alignment != house.Alignment {
return nil, fmt.Errorf("alignment requirement not met")
}
}
// Check guild level requirement
if house.GuildLevel > 0 {
guildLevel, err := playerManager.GetPlayerGuildLevel(characterID)
if err != nil {
return nil, fmt.Errorf("failed to get guild level: %w", err)
}
if guildLevel < house.GuildLevel {
return nil, fmt.Errorf("guild level requirement not met")
}
}
// Check max houses limit
currentHouses, err := hm.GetCharacterHouses(characterID)
if err != nil {
return nil, fmt.Errorf("failed to get current houses: %w", err)
}
if len(currentHouses) >= hm.config.MaxHousesPerPlayer {
return nil, fmt.Errorf("maximum number of houses reached")
}
// Deduct costs
if err := playerManager.DeductPlayerCoins(characterID, house.CostCoins); err != nil {
return nil, fmt.Errorf("failed to deduct coins: %w", err)
}
if err := playerManager.DeductPlayerStatus(characterID, house.CostStatus); err != nil {
return nil, fmt.Errorf("failed to deduct status: %w", err)
}
// Create character house
characterHouse := &CharacterHouse{
CharacterID: characterID,
HouseID: houseID,
InstanceID: hm.generateInstanceID(),
UpkeepDue: time.Now().Add(7 * 24 * time.Hour), // Weekly upkeep
EscrowCoins: 0,
EscrowStatus: 0,
Status: HouseStatusActive,
Settings: CharacterHouseSettings{
VisitPermission: VisitPermissionFriends,
AllowFriends: true,
ShowOnDirectory: true,
},
AccessList: make(map[int32]HouseAccess),
Items: []HouseItem{},
History: []HouseHistory{{
Timestamp: time.Now(),
Amount: house.CostCoins,
Status: house.CostStatus,
Reason: "House Purchase",
CharacterID: characterID,
Type: TransactionPurchase,
}},
}
// Save to database if database is available
if hm.db != nil {
if err := hm.saveCharacterHouseToDBInternal(ctx, characterHouse); err != nil {
return nil, fmt.Errorf("failed to save house: %w", err)
}
}
// Add to memory
hm.mu.Lock()
hm.characterHouses[characterHouse.UniqueID] = characterHouse
hm.characterIndex[characterID] = append(hm.characterIndex[characterID], characterHouse)
hm.mu.Unlock()
hm.logger.LogInfo("housing", "Character %d purchased house type %d", characterID, houseID)
return characterHouse, nil
}
// SendHousePurchasePacket sends a house purchase packet to a client
func (hm *HousingManager) SendHousePurchasePacket(characterID int32, clientVersion int32, house *House) error {
def, exists := packets.GetPacket("PlayerHousePurchase")
if !exists {
return fmt.Errorf("PlayerHousePurchase packet definition not found")
}
builder := packets.NewPacketBuilder(def, uint32(clientVersion), 0)
packetData := map[string]any{
"house_name": house.Name,
"house_id": uint64(house.ID),
"spawn_id": uint32(0),
"purchase_coins": house.CostCoins,
"purchase_status": house.CostStatus,
"upkeep_coins": house.UpkeepCoins,
"upkeep_status": house.UpkeepStatus,
"vendor_vault_slots": house.VaultSlots,
"additional_reqs": fmt.Sprintf("Alignment: %s", AlignmentNames[house.Alignment]),
"enable_buy": 1,
}
packet, err := builder.Build(packetData)
if err != nil {
return fmt.Errorf("failed to build packet: %w", err)
}
// TODO: Send packet to client when client interface is available
_ = packet
hm.logger.LogDebug("housing", "Built house purchase packet for character %d", characterID)
return nil
}
// SendCharacterHousesPacket sends a character's house list to client
func (hm *HousingManager) SendCharacterHousesPacket(characterID int32, clientVersion int32) error {
houses, err := hm.GetCharacterHouses(characterID)
if err != nil {
return fmt.Errorf("failed to get character houses: %w", err)
}
def, exists := packets.GetPacket("CharacterHousingList")
if !exists {
return fmt.Errorf("CharacterHousingList packet definition not found")
}
builder := packets.NewPacketBuilder(def, uint32(clientVersion), 0)
houseArray := make([]map[string]any, len(houses))
for i, house := range houses {
houseType, exists := hm.GetHouse(house.HouseID)
houseName := "Unknown"
if exists {
houseName = houseType.Name
}
houseArray[i] = map[string]any{
"house_id": uint64(house.UniqueID),
"zone": house.HouseID,
"house_city": "Qeynos", // Default city
"house_address": house.Settings.HouseName,
"house_description": fmt.Sprintf("Upkeep due: %s", house.UpkeepDue.Format("2006-01-02")),
"index": i,
}
if house.Settings.HouseName == "" {
houseArray[i]["house_address"] = houseName
}
}
packetData := map[string]any{
"num_houses": len(houses),
"house_array": houseArray,
}
packet, err := builder.Build(packetData)
if err != nil {
return fmt.Errorf("failed to build packet: %w", err)
}
// TODO: Send packet to client when client interface is available
_ = packet
hm.logger.LogDebug("housing", "Built housing list packet for character %d (%d houses)", characterID, len(houses))
return nil
}
// PayUpkeep handles upkeep payment for a house
func (hm *HousingManager) PayUpkeep(ctx context.Context, houseUniqueID int64, playerManager PlayerManager) error {
hm.mu.RLock()
characterHouse, exists := hm.characterHouses[houseUniqueID]
hm.mu.RUnlock()
if !exists {
return fmt.Errorf("character house %d not found", houseUniqueID)
}
house, exists := hm.GetHouse(characterHouse.HouseID)
if !exists {
return fmt.Errorf("house type %d not found", characterHouse.HouseID)
}
// Check if player can afford upkeep
canAfford, err := playerManager.CanPlayerAffordHouse(characterHouse.CharacterID, house.UpkeepCoins, house.UpkeepStatus)
if err != nil {
return fmt.Errorf("failed to check affordability: %w", err)
}
if !canAfford {
return fmt.Errorf("insufficient funds for upkeep")
}
// Deduct upkeep costs
if err := playerManager.DeductPlayerCoins(characterHouse.CharacterID, house.UpkeepCoins); err != nil {
return fmt.Errorf("failed to deduct upkeep coins: %w", err)
}
if err := playerManager.DeductPlayerStatus(characterHouse.CharacterID, house.UpkeepStatus); err != nil {
return fmt.Errorf("failed to deduct upkeep status: %w", err)
}
// Update upkeep due date
characterHouse.mu.Lock()
characterHouse.UpkeepDue = time.Now().Add(7 * 24 * time.Hour) // Next week
characterHouse.History = append(characterHouse.History, HouseHistory{
Timestamp: time.Now(),
Amount: house.UpkeepCoins,
Status: house.UpkeepStatus,
Reason: "Upkeep Payment",
CharacterID: characterHouse.CharacterID,
Type: TransactionUpkeep,
})
characterHouse.mu.Unlock()
// Save to database if database is available
if hm.db != nil {
if err := hm.saveCharacterHouseToDBInternal(ctx, characterHouse); err != nil {
return fmt.Errorf("failed to save upkeep payment: %w", err)
}
}
hm.logger.LogInfo("housing", "Paid upkeep for house %d (coins: %d, status: %d)",
houseUniqueID, house.UpkeepCoins, house.UpkeepStatus)
return nil
}
// processUpkeepLoop runs upkeep processing in background
func (hm *HousingManager) processUpkeepLoop(ctx context.Context) {
ticker := time.NewTicker(1 * time.Hour)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
hm.logger.LogInfo("housing", "Stopping upkeep processing")
return
case <-ticker.C:
if err := hm.processUpkeep(ctx); err != nil {
hm.logger.LogError("housing", "Failed to process upkeep: %v", err)
}
}
}
}
// processUpkeep processes houses due for upkeep
func (hm *HousingManager) processUpkeep(ctx context.Context) error {
hm.mu.RLock()
houses := make([]*CharacterHouse, 0)
now := time.Now()
cutoff := now.Add(24 * time.Hour) // Houses due in next 24 hours
for _, house := range hm.characterHouses {
if house.UpkeepDue.Before(cutoff) && house.Status == HouseStatusActive {
houses = append(houses, house)
}
}
hm.mu.RUnlock()
hm.logger.LogInfo("housing", "Processing upkeep for %d houses", len(houses))
for _, house := range houses {
if house.UpkeepDue.Before(now) {
// Mark as upkeep due
house.mu.Lock()
house.Status = HouseStatusUpkeepDue
house.mu.Unlock()
hm.logger.LogWarning("housing", "House %d upkeep is overdue", house.UniqueID)
// If enabled, could handle foreclosure here
if hm.config.EnableForeclosure {
gracePeriod := time.Duration(hm.config.UpkeepGracePeriod) * time.Second
if house.UpkeepDue.Add(gracePeriod).Before(now) {
house.mu.Lock()
house.Status = HouseStatusForeclosed
house.mu.Unlock()
hm.logger.LogInfo("housing", "House %d foreclosed due to unpaid upkeep", house.UniqueID)
}
}
}
}
return nil
}
// generateInstanceID generates a unique instance ID for houses
func (hm *HousingManager) generateInstanceID() int32 {
// Simple implementation - in production would use proper ID generation
return int32(time.Now().Unix())
}
// Database operations (internal)
func (hm *HousingManager) loadHousesFromDB(ctx context.Context) ([]*House, error) {
query := `
SELECT id, name, cost_coins, cost_status, upkeep_coins, upkeep_status,
vault_slots, alignment, guild_level, zone_id, exit_zone_id,
exit_x, exit_y, exit_z, exit_heading
FROM character_house_zones
ORDER BY id
`
rows, err := hm.db.Query(query)
if err != nil {
return nil, fmt.Errorf("failed to query houses: %w", err)
}
defer rows.Close()
houses := make([]*House, 0)
for rows.Next() {
house := &House{}
err := rows.Scan(
&house.ID, &house.Name, &house.CostCoins, &house.CostStatus,
&house.UpkeepCoins, &house.UpkeepStatus, &house.VaultSlots,
&house.Alignment, &house.GuildLevel, &house.ZoneID, &house.ExitZoneID,
&house.ExitX, &house.ExitY, &house.ExitZ, &house.ExitHeading,
)
if err != nil {
return nil, fmt.Errorf("failed to scan house: %w", err)
}
houses = append(houses, house)
}
return houses, nil
}
func (hm *HousingManager) loadCharacterHousesFromDB(ctx context.Context, characterID int32) ([]*CharacterHouse, error) {
query := `
SELECT unique_id, char_id, house_id, instance_id, upkeep_due,
escrow_coins, escrow_status, status, house_name,
visit_permission, public_note, private_note, allow_friends,
allow_guild, require_approval, show_on_directory, allow_decoration
FROM character_houses
WHERE char_id = ?
ORDER BY unique_id
`
rows, err := hm.db.Query(query, characterID)
if err != nil {
return nil, fmt.Errorf("failed to query character houses: %w", err)
}
defer rows.Close()
houses := make([]*CharacterHouse, 0)
for rows.Next() {
house := &CharacterHouse{
AccessList: make(map[int32]HouseAccess),
Items: []HouseItem{},
History: []HouseHistory{},
}
err := rows.Scan(
&house.UniqueID, &house.CharacterID, &house.HouseID, &house.InstanceID,
&house.UpkeepDue, &house.EscrowCoins, &house.EscrowStatus, &house.Status,
&house.Settings.HouseName, &house.Settings.VisitPermission,
&house.Settings.PublicNote, &house.Settings.PrivateNote,
&house.Settings.AllowFriends, &house.Settings.AllowGuild,
&house.Settings.RequireApproval, &house.Settings.ShowOnDirectory,
&house.Settings.AllowDecoration,
)
if err != nil {
return nil, fmt.Errorf("failed to scan character house: %w", err)
}
houses = append(houses, house)
}
return houses, nil
}
func (hm *HousingManager) saveCharacterHouseToDBInternal(ctx context.Context, house *CharacterHouse) error {
if house.UniqueID == 0 {
// Insert new house
query := `
INSERT INTO character_houses (
char_id, house_id, instance_id, upkeep_due, escrow_coins,
escrow_status, status, house_name, visit_permission,
public_note, private_note, allow_friends, allow_guild,
require_approval, show_on_directory, allow_decoration
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`
result, err := hm.db.Exec(query,
house.CharacterID, house.HouseID, house.InstanceID, house.UpkeepDue,
house.EscrowCoins, house.EscrowStatus, house.Status,
house.Settings.HouseName, house.Settings.VisitPermission,
house.Settings.PublicNote, house.Settings.PrivateNote,
house.Settings.AllowFriends, house.Settings.AllowGuild,
house.Settings.RequireApproval, house.Settings.ShowOnDirectory,
house.Settings.AllowDecoration,
)
if err != nil {
return fmt.Errorf("failed to insert character house: %w", err)
}
id, err := result.LastInsertId()
if err != nil {
return fmt.Errorf("failed to get insert ID: %w", err)
}
house.UniqueID = id
} else {
// Update existing house
query := `
UPDATE character_houses SET
upkeep_due = ?, escrow_coins = ?, escrow_status = ?, status = ?,
house_name = ?, visit_permission = ?, public_note = ?, private_note = ?,
allow_friends = ?, allow_guild = ?, require_approval = ?,
show_on_directory = ?, allow_decoration = ?
WHERE unique_id = ?
`
_, err := hm.db.Exec(query,
house.UpkeepDue, house.EscrowCoins, house.EscrowStatus, house.Status,
house.Settings.HouseName, house.Settings.VisitPermission,
house.Settings.PublicNote, house.Settings.PrivateNote,
house.Settings.AllowFriends, house.Settings.AllowGuild,
house.Settings.RequireApproval, house.Settings.ShowOnDirectory,
house.Settings.AllowDecoration, house.UniqueID,
)
if err != nil {
return fmt.Errorf("failed to update character house: %w", err)
}
}
return nil
}
// Shutdown gracefully shuts down the housing manager
func (hm *HousingManager) Shutdown(ctx context.Context) error {
hm.logger.LogInfo("housing", "Shutting down housing manager")
// Any cleanup would go here
return nil
}
// Utility functions
// FormatUpkeepDue formats upkeep due date for display
func FormatUpkeepDue(upkeepDue time.Time) string {
now := time.Now()
if upkeepDue.Before(now) {
duration := now.Sub(upkeepDue)
days := int(duration.Hours() / 24)
if days == 0 {
return "Overdue (today)"
}
return fmt.Sprintf("Overdue (%d days)", days)
} else {
duration := upkeepDue.Sub(now)
days := int(duration.Hours() / 24)
if days == 0 {
return "Due today"
}
return fmt.Sprintf("Due in %d days", days)
}
}
// FormatCurrency formats currency amounts for display
func FormatCurrency(amount int64) string {
if amount < 0 {
return fmt.Sprintf("-%s", FormatCurrency(-amount))
}
if amount >= 10000 { // 1 gold = 10000 copper
gold := amount / 10000
remainder := amount % 10000
if remainder == 0 {
return fmt.Sprintf("%dg", gold)
} else {
silver := remainder / 100
copper := remainder % 100
if copper == 0 {
return fmt.Sprintf("%dg %ds", gold, silver)
} else {
return fmt.Sprintf("%dg %ds %dc", gold, silver, copper)
}
}
} else if amount >= 100 {
silver := amount / 100
copper := amount % 100
if copper == 0 {
return fmt.Sprintf("%ds", silver)
} else {
return fmt.Sprintf("%ds %dc", silver, copper)
}
} else {
return fmt.Sprintf("%dc", amount)
}
}