# Assassin Skill Implementation Plan ## 1. Overview Assassin is the Slayer analog for The House of Icarus. A special NPC called **The Client** assigns the player a task to kill a certain number of a specific mob type. Killing mobs "on task" awards Assassin XP in addition to normal combat XP. Some mobs require a minimum Assassin level to attack. Some mobs deal extra damage unless the player wears specific protective equipment. Some mobs cannot be killed below 1 HP without using a finishing blow item on them. Players earn **Reputation** (analogous to Slayer Points) on task completion, with streak bonuses at milestones. Reputation is spent in the Client's Reputation Shop to skip/extend tasks, unlock permanent perks, and buy auto-finishing blow unlocks. The Assassin skill already exists as a stub in `internal/player/player.go` (constant `Assassin SkillName = "assassin"`, abbreviation `"asm"`). XP is tracked but no behaviors, items, mobs, or commands exist yet. --- ## 2. Task System Architecture ### Player Flags for Task State All task state is stored in player flags (`p.Flags` map, persisted to character YAML). Using player flags means tasks are per-character, saved automatically, and require no schema changes to `Player`. | Flag Key | Type | Description | |---|---|---| | `assassin_task_mob` | `string` | Mob definition ID of current task (e.g. `"slug"`) | | `assassin_task_total` | `int` | Total kills assigned for this task | | `assassin_task_remaining` | `int` | Kills remaining on current task | | `assassin_tasks_completed` | `int` | Lifetime tasks completed (never resets) | | `assassin_streak` | `int` | Consecutive tasks completed without skipping | | `assassin_reputation` | `int` | Current unspent reputation points | | `assassin_unlocked_` | `bool` | Purchased permanent unlocks (e.g. `assassin_unlocked_auto_salt`) | ### Task Lifecycle 1. Player talks to The Client and selects "I need a job." 2. Client calls `assignAssassinTask(p)` which picks a mob from the task table based on `p.Level(player.Assassin)`. 3. Player flags are set: `assassin_task_mob`, `assassin_task_total`, `assassin_task_remaining`. 4. Player kills assigned mobs. Each on-task kill: - Decrements `assassin_task_remaining` - Awards Assassin XP = `mob.MaxHP * 2` - Displays task progress message: `"Assassin task: 12 of 45 slugs remaining."` 5. When `assassin_task_remaining` reaches 0: - Increments `assassin_tasks_completed` and `assassin_streak` - Awards base reputation (1) + any streak bonus - Clears `assassin_task_mob` - Displays completion message with reputation earned 6. Player returns to Client for a new task. ### How Flags Are Read/Written Player flags use `map[string]any`. Integer flags are stored as `int` but may unmarshal from YAML as `int`, `int64`, or `float64`. A helper function must handle type assertion: ```go // in internal/game/assassin.go func getPlayerFlagInt(p *player.Player, key string) int { if p.Flags == nil { return 0 } val, ok := p.Flags[key] if !ok { return 0 } switch v := val.(type) { case int: return v case int64: return int(v) case float64: return int(v) } return 0 } func getPlayerFlagString(p *player.Player, key string) string { if p.Flags == nil { return "" } val, ok := p.Flags[key] if !ok { return "" } s, _ := val.(string) return s } func setPlayerFlag(p *player.Player, key string, val any) { if p.Flags == nil { p.Flags = make(map[string]any) } p.Flags[key] = val } ``` --- ## 3. Commands ### `task` (Instant Command) Displays the player's current Assassin task status. No arguments. **Output examples:** - Has task: `"Assassin task: Kill slugs. 12 of 45 remaining."` - No task: `"You don't have an Assassin task. Talk to The Client to get one."` - Task just completed: `"You have no active task. Talk to The Client for a new assignment."` Also shows streak and reputation: ``` Assassin task: Kill slugs. 12 of 45 remaining. Streak: 7 | Reputation: 42 ``` **Implementation:** Create `internal/game/cmd_task.go`: ```go package game import ( "fmt" "thehouseoficarus/internal/net" "thehouseoficarus/internal/player" ) func (g *Game) doTask(sess *net.Session) { p := sess.Player.(*player.Player) mobID := getPlayerFlagString(p, "assassin_task_mob") remaining := getPlayerFlagInt(p, "assassin_task_remaining") total := getPlayerFlagInt(p, "assassin_task_total") streak := getPlayerFlagInt(p, "assassin_streak") rep := getPlayerFlagInt(p, "assassin_reputation") if mobID == "" || remaining <= 0 { sess.WriteLine("You don't have an Assassin task. Talk to The Client to get one.") } else { def, err := g.MobStore.LoadDef(mobID) name := mobID if err == nil { name = def.Name } sess.WriteLine(fmt.Sprintf("Assassin task: Kill %ss. %d of %d remaining.", name, remaining, total)) } sess.WriteLine(fmt.Sprintf("Streak: %d | Reputation: %d", streak, rep)) } ``` ### No New Combat Commands The existing `attack`/`kill` command is extended in `cmd_attack.go` to check assassin level requirements, apply finishing blow mechanics, and award on-task XP. No new verbs are needed. --- ## 4. New Files to Create ### Go Files | File | Purpose | |---|---| | `internal/game/cmd_task.go` | `doTask()` handler — displays current task info | | `internal/game/assassin.go` | Core assassin logic: task table, `assignAssassinTask()`, `onAssassinKill()`, `assassinTaskTable`, flag helpers | ### YAML Data Files | File | Purpose | |---|---| | `data/mobs/slug.yaml` | Slug mob (assassin level 1, finishing blow: salt) | | `data/mobs/drone.yaml` | Drone mob (assassin level 15, damage_without: insulated_gloves) | | `data/mobs/crawler.yaml` | Crawler mob (assassin level 30, finishing blow: acid_vial) | | `data/mobs/phantom.yaml` | Phantom mob (assassin level 45, damage_without: spectral_visor) | | `data/mobs/client.yaml` | The Client NPC (protected, unique, behavior: client_talk) | | `data/items/salt.yaml` | Salt (stackable, finishing blow consumable) | | `data/items/acid_vial.yaml` | Acid vial (stackable, finishing blow consumable) | | `data/items/insulated_gloves.yaml` | Insulated gloves (hands slot, minimal stats) | | `data/items/spectral_visor.yaml` | Spectral visor (head slot, minimal stats) | | `data/behaviors/client_talk.yaml` | Client dialog tree (task assignment, reputation shop) | | `data/help/task.yaml` | Help topic for `task` command | | `data/help/assassin.yaml` | Help topic for assassin skill overview | | `data/rooms/50.yaml` | Assassin Den room (Client's location) — or modify an existing room | ### Rooms that need mobs added | Room | Mob(s) Added | |---|---| | New room (e.g. 50) | `client` | | New room (e.g. 51) | `slug`, `slug` | | New room (e.g. 52) | `drone`, `drone` | | New room (e.g. 53) | `crawler` | | New room (e.g. 54) | `phantom` | --- ## 5. Code Changes to Existing Files ### `internal/world/mob.go` — MobDef Extensions Add three new fields to `MobDef`: ```go type MobDef struct { // ... existing fields ... AssassinLevel int `yaml:"assassin_level"` FinishingBlow string `yaml:"finishing_blow"` DamageWithout string `yaml:"damage_without"` } ``` Also add these fields to `MobInstance` so they're accessible at runtime: ```go type MobInstance struct { // ... existing fields ... AssassinLevel int FinishingBlow string DamageWithout string } ``` In `SeedMobs()`, copy these fields from def to instance: ```go inst := &MobInstance{ // ... existing fields ... AssassinLevel: dw.def.AssassinLevel, FinishingBlow: dw.def.FinishingBlow, DamageWithout: dw.def.DamageWithout, } ``` ### `internal/game/cmd_attack.go` — Attack Modifications #### a) Assassin Level Check in `doAttack()` After the `mob.Protected` check (line 38-41) and before `startCombat()` (line 49), add: ```go if mob.AssassinLevel > 0 && p.Level(player.Assassin) < mob.AssassinLevel { sess.WriteLine(fmt.Sprintf("You need Assassin level %d to attack %s.", mob.AssassinLevel, mobDisplayName(mob, true))) return } ``` #### b) Finishing Blow — HP Floor in `playerAttack()` After `mob.HP -= dmg` (line 199), add the finishing blow HP floor: ```go if mob.FinishingBlow != "" && mob.HP <= 0 { mob.HP = 1 } ``` This prevents the mob from dying via normal combat. The mob stays at 1 HP. The player must `use on ` to kill it. When the mob is at 1 HP and has a finishing blow requirement, display a hint on every attack: ```go if mob.FinishingBlow != "" && mob.HP == 1 { def, _ := g.ItemStore.Load(mob.FinishingBlow) itemName := mob.FinishingBlow if def != nil { itemName = def.Name } sess.WriteLine(g.colorize(sess, "warning", fmt.Sprintf(" %s is immune! Use %s on it to finish it off.", mobDisplayName(mob, false), itemName))) } ``` #### c) Damage Increase in `mobAttack()` After calculating `dmg` in `mobAttack()` (line 254), before applying to `p.HP`, add: ```go if mob.DamageWithout != "" { hasProtection := false for _, itemID := range p.Equipment { if itemID == mob.DamageWithout { hasProtection = true break } } if !hasProtection { dmg = dmg * 3 / 2 // 1.5x damage } } ``` #### d) On-Task Kill XP in `endCombat()` After the victory message (line 312) and before loot drops (line 324), add the assassin task processing: ```go if mob != nil && mob.HP <= 0 { sess.WriteLine(g.colorize(sess, "victory", fmt.Sprintf("\nYou have defeated %s!", mobDisplayName(mob, true)))) // --- NEW: Assassin task processing --- g.onAssassinKill(sess, p, mob) // --- END NEW --- // ... existing broadcast, loot drops, respawn ... } ``` ### `internal/game/game.go` — Register `task` Command #### a) In `classifyCommand()` (line 136) Add `"task"` to the Instant list: ```go case "say", "score", "sc", "inventory", "i", "inv", "look", "l", "exits", "help", "map", "option", "options", "alias", "unalias", "description", "desc", "queued", "color", "colors", "colortable", "prompt", "style", "task": return ClassInstant ``` #### b) In `executeCommand()` (line 249) Add a case after `"sc", "score"`: ```go case "task": g.doTask(sess) ``` ### `internal/game/game.go` — Finishing Blow via `use` on Mob The `doUse()` function in `cmd_use.go` currently handles `use on ` for objects. It needs a new path: if the target is a mob that the player is in combat with and the mob has `finishing_blow` set and is at 1 HP, consume the item and kill the mob. In the `doUse()` function, after failing to find an object target, attempt to find a mob target: ```go // After object lookup fails or before object lookup // Check if target is a mob in combat with the player cs := combat.GetCombat(p.Name) if cs != nil { mob := g.MobStore.GetInstance(cs.MobID) if mob != nil && mob.HP == 1 && mob.FinishingBlow != "" { if mob.MatchQuality(targetName) != world.MatchNone { if strings.ToLower(itemName) == mob.FinishingBlow || itemMatchesDef(itemName, mob.FinishingBlow, g.ItemStore) { if !p.HasItem(mob.FinishingBlow) { sess.WriteLine(fmt.Sprintf("You don't have any %s.", mob.FinishingBlow)) return } // Check for auto-finish unlock autoKey := "assassin_unlocked_auto_" + mob.FinishingBlow if getPlayerFlagBool(p, autoKey) { // Don't consume } else { p.RemoveItem(mob.FinishingBlow, 1) } def, _ := g.ItemStore.Load(mob.FinishingBlow) itemDisplayName := mob.FinishingBlow if def != nil { itemDisplayName = def.Name } sess.WriteLine(fmt.Sprintf("You use the %s on %s!", itemDisplayName, mobDisplayName(mob, true))) mob.HP = 0 g.endCombat(sess, p, mob) return } } } } ``` A helper is needed to match an item input string to a finishing blow item ID: ```go func itemMatchesDef(input, itemID string, store *object.ItemStore) bool { def, err := store.Load(itemID) if err != nil { return false } return def.MatchesName(input) } ``` ### `internal/game/action_state.go` — No Changes Needed The existing `ActionCombating` action type covers all combat states including assassin-related combat. No new action types are needed. --- ## 6. The Client NPC ### Mob Definition: `data/mobs/client.yaml` ```yaml id: client name: The Client description: "A shadowy figure in a dark coat. They speak in clipped, measured tones and seem to know everything about every creature in the sector." unique: true protected: true behavior: client_talk attack: 1 strength: 1 defense: 1 hp: 100 speed: 5 aggressive: false respawn_ticks: 30 idle_descriptions: - "reviews a holographic dossier" - "marks a location on a worn star chart" - "flips a credit chit between their fingers" - "mutters coordinates under their breath" - "glances at you appraisingly" - "taps a data pad with a stylus" ``` ### Talk Behavior: `data/behaviors/client_talk.yaml` The Client's dialog tree is complex. Since the talk system currently doesn't support dynamic logic (task assignment requires Go code), the approach is: **Option A (Recommended): Hybrid approach — use `set_player_flags` with a sentinel value, then intercept in `applyNodeAction`.** Add a new `NodeAction` field called `assign_task` (boolean) that triggers `assignAssassinTask()` from Go code. **Required change to `internal/action/behavior.go`:** ```go type NodeAction struct { // ... existing fields ... AssignTask bool `yaml:"assign_task"` SkipTask bool `yaml:"skip_task"` ExtendTask bool `yaml:"extend_task"` } ``` **Required change to `internal/game/action_talk.go` in `applyNodeAction()`:** After existing action processing (line 218), add: ```go if na.AssignTask { g.assignAssassinTask(sess, p) } if na.SkipTask { g.skipAssassinTask(sess, p) } if na.ExtendTask { g.extendAssassinTask(sess, p) } ``` **Full behavior YAML:** ```yaml id: client_talk type: talk nodes: start: message: "The Client looks up from their data pad. \"What do you need?\"" options: - text: "\"I need a job.\"" goto: assign_task condition: player_flag: assassin_task_mob not: true - text: "\"I've finished my task.\"" goto: task_complete condition: all_of: - player_flag: assassin_task_mob not: true value: "" - text: "\"What's my current task?\"" goto: current_task condition: player_flag: assassin_task_mob not: true - text: "\"I want to skip my task.\"" goto: skip_confirm condition: player_flag: assassin_task_mob not: true - text: "\"I want to extend my task.\"" goto: extend_confirm condition: player_flag: assassin_task_mob not: true - text: "\"I'd like to browse the Reputation Shop.\"" goto: rep_shop - text: "\"I need supplies.\"" goto: supplies - text: "\"Goodbye.\"" end: true assign_task: message: "\"Let me check what's available...\" The Client scrolls through their data pad." action: assign_task: true options: - text: "\"Understood.\"" end: true - text: "\"What else do you have?\"" goto: start current_task: message: "The Client checks their records." options: - text: "\"Okay.\"" end: true skip_confirm: message: "\"Skipping a task costs 30 Reputation and resets your streak. Are you sure?\"" options: - text: "\"Yes, skip it.\"" goto: skip_done - text: "\"Never mind.\"" goto: start skip_done: message: "\"Task cancelled. Your streak has been reset.\"" action: skip_task: true options: - text: "\"Give me a new one.\"" goto: assign_task - text: "\"Goodbye.\"" end: true extend_confirm: message: "\"Extending your task costs 30 Reputation and adds more kills. Want to proceed?\"" options: - text: "\"Yes, extend it.\"" goto: extend_done - text: "\"Never mind.\"" goto: start extend_done: message: "\"Done. I've added more targets to your contract.\"" action: extend_task: true options: - text: "\"Thanks.\"" end: true rep_shop: message: "\"Here's what I've got. All purchases are permanent.\"" options: - text: "\"Auto-finish: Salt (200 Rep) - Never consume salt on finishing blows.\"" goto: buy_auto_salt - text: "\"Auto-finish: Acid Vial (200 Rep) - Never consume acid vials on finishing blows.\"" goto: buy_auto_acid - text: "\"Unlock Superior Mobs (300 Rep) - Rare superior variants may spawn.\"" goto: buy_superiors - text: "\"Unlock Extended Tasks (100 Rep) - Allows extending tasks.\"" goto: buy_extend_unlock - text: "\"Back.\"" goto: start buy_auto_salt: message: "\"Auto-salt purchased. You'll no longer consume salt on finishing blows.\"" action: set_player_flags: assassin_unlocked_auto_salt: true options: - text: "\"Thanks.\"" goto: rep_shop buy_auto_acid: message: "\"Auto-acid purchased. Acid vials will no longer be consumed.\"" action: set_player_flags: assassin_unlocked_auto_acid_vial: true options: - text: "\"Thanks.\"" goto: rep_shop buy_superiors: message: "\"Superior encounters unlocked. Watch yourself out there.\"" action: set_player_flags: assassin_unlocked_superiors: true options: - text: "\"Thanks.\"" goto: rep_shop buy_extend_unlock: message: "\"You can now extend tasks via our conversation.\"" action: set_player_flags: assassin_unlocked_extend: true options: - text: "\"Thanks.\"" goto: rep_shop supplies: message: "\"I stock everything you need for the job. Cheap, too.\"" options: - text: "\"Buy salt (5 credits each).\"" goto: buy_salt condition: min_credits: 5 - text: "\"Buy acid vial (10 credits each).\"" goto: buy_acid condition: min_credits: 10 - text: "\"Buy insulated gloves (50 credits).\"" goto: buy_gloves condition: min_credits: 50 - text: "\"Buy spectral visor (75 credits).\"" goto: buy_visor condition: min_credits: 75 - text: "\"Back.\"" goto: start buy_salt: message: "\"Here you go.\" The Client slides a packet of salt across the table." action: give_item: salt cost: 5 options: - text: "\"Buy more.\"" goto: buy_salt condition: min_credits: 5 - text: "\"Thanks.\"" goto: supplies buy_acid: message: "\"Handle with care.\" The Client passes you a vial of corrosive acid." action: give_item: acid_vial cost: 10 options: - text: "\"Buy more.\"" goto: buy_acid condition: min_credits: 10 - text: "\"Thanks.\"" goto: supplies buy_gloves: message: "\"These'll keep the current from frying your hands.\" The Client tosses you a pair of thick rubber gloves." action: give_item: insulated_gloves cost: 50 options: - text: "\"Thanks.\"" goto: supplies buy_visor: message: "\"Spectral frequency filter. Makes the invisible visible — and keeps their attacks from scrambling your brain.\"" action: give_item: spectral_visor cost: 75 options: - text: "\"Thanks.\"" goto: supplies ``` **Note on dynamic content in dialog:** The talk system currently renders static `message` strings. For the "current task" and "assign task" nodes, the message content depends on player state. Two approaches: **Approach 1 (Simple):** The `assign_task` action in `applyNodeAction` writes a message to the session directly. The node's `message` is generic ("Let me check what's available...") and the actual task details are output by the Go function. **Approach 2 (Placeholder):** Support a `{assassin_task}` placeholder in talk node messages that `showTalkNode` expands before display. This is more complex but cleaner. **Recommended: Approach 1.** The `assignAssassinTask()` function outputs the task details via `sess.WriteLine()` after the node message is shown. --- ## 7. Task Assignment Logic ### File: `internal/game/assassin.go` ```go package game import ( "fmt" "math/rand" "thehouseoficarus/internal/net" "thehouseoficarus/internal/player" ) type assassinTaskEntry struct { MobID string MinLevel int MaxLevel int MinCount int MaxCount int Weight int } var assassinTaskTable = []assassinTaskEntry{ // Level 1-10: basic mobs {"man", 1, 99, 10, 30, 10}, {"cow", 1, 99, 10, 30, 10}, // Assassin-specific mobs {"slug", 1, 99, 15, 45, 15}, {"drone", 15, 99, 20, 50, 12}, {"crawler", 30, 99, 15, 40, 10}, {"phantom", 45, 99, 10, 30, 8}, } func (g *Game) assignAssassinTask(sess *net.Session, p *player.Player) { level := p.Level(player.Assassin) var eligible []assassinTaskEntry totalWeight := 0 for _, entry := range assassinTaskTable { if level >= entry.MinLevel && level <= entry.MaxLevel { eligible = append(eligible, entry) totalWeight += entry.Weight } } if len(eligible) == 0 { sess.WriteLine("The Client shakes their head. \"Nothing available for your level.\"") return } // Weighted random selection roll := rand.Intn(totalWeight) var chosen assassinTaskEntry for _, entry := range eligible { roll -= entry.Weight if roll < 0 { chosen = entry break } } // Random count within range count := chosen.MinCount + rand.Intn(chosen.MaxCount-chosen.MinCount+1) setPlayerFlag(p, "assassin_task_mob", chosen.MobID) setPlayerFlag(p, "assassin_task_total", count) setPlayerFlag(p, "assassin_task_remaining", count) g.AccountStore.SaveCharacter(p) def, err := g.MobStore.LoadDef(chosen.MobID) name := chosen.MobID if err == nil { name = def.Name } sess.WriteLine(g.colorize(sess, "assassin_task", fmt.Sprintf("\"Your target: %d %ss. Get to work.\"", count, name))) } func (g *Game) onAssassinKill(sess *net.Session, p *player.Player, mob *world.MobInstance) { taskMob := getPlayerFlagString(p, "assassin_task_mob") if taskMob == "" || taskMob != mob.DefID { return } remaining := getPlayerFlagInt(p, "assassin_task_remaining") if remaining <= 0 { return } // Award Assassin XP xp := mob.MaxHP * 2 newLevel := p.AddSkillXP(player.Assassin, xp) if newLevel > 0 { sess.WriteLine(g.colorize(sess, "level_up", fmt.Sprintf("*** You are now level %d Assassin! ***", newLevel))) } if p.OptionBool("xp_drops") { sess.WriteLine(g.colorize(sess, "xp", fmt.Sprintf(" (+%dxp asm)", xp))) } remaining-- setPlayerFlag(p, "assassin_task_remaining", remaining) if remaining <= 0 { // Task complete completed := getPlayerFlagInt(p, "assassin_tasks_completed") + 1 streak := getPlayerFlagInt(p, "assassin_streak") + 1 setPlayerFlag(p, "assassin_tasks_completed", completed) setPlayerFlag(p, "assassin_streak", streak) setPlayerFlag(p, "assassin_task_mob", "") setPlayerFlag(p, "assassin_task_remaining", 0) setPlayerFlag(p, "assassin_task_total", 0) // Calculate reputation rep := 1 // base bonus := streakBonus(streak) rep += bonus currentRep := getPlayerFlagInt(p, "assassin_reputation") setPlayerFlag(p, "assassin_reputation", currentRep+rep) sess.WriteLine(g.colorize(sess, "assassin_task", fmt.Sprintf("\n*** Assassin task complete! ***"))) if bonus > 0 { sess.WriteLine(g.colorize(sess, "assassin_task", fmt.Sprintf(" Streak bonus! %d tasks in a row. +%d bonus reputation.", streak, bonus))) } sess.WriteLine(g.colorize(sess, "assassin_task", fmt.Sprintf(" Reputation earned: %d (total: %d)", rep, currentRep+rep))) } else { total := getPlayerFlagInt(p, "assassin_task_total") def, _ := g.MobStore.LoadDef(taskMob) name := taskMob if def != nil { name = def.Name } sess.WriteLine(g.colorize(sess, "assassin_task", fmt.Sprintf(" Assassin task: %d of %d %ss remaining.", remaining, total, name))) } g.AccountStore.SaveCharacter(p) } func (g *Game) skipAssassinTask(sess *net.Session, p *player.Player) { rep := getPlayerFlagInt(p, "assassin_reputation") if rep < 30 { sess.WriteLine("You don't have enough Reputation to skip. (Need 30, have " + fmt.Sprint(rep) + ")") return } setPlayerFlag(p, "assassin_reputation", rep-30) setPlayerFlag(p, "assassin_task_mob", "") setPlayerFlag(p, "assassin_task_remaining", 0) setPlayerFlag(p, "assassin_task_total", 0) setPlayerFlag(p, "assassin_streak", 0) // Reset streak g.AccountStore.SaveCharacter(p) } func (g *Game) extendAssassinTask(sess *net.Session, p *player.Player) { rep := getPlayerFlagInt(p, "assassin_reputation") if rep < 30 { sess.WriteLine("You don't have enough Reputation to extend. (Need 30, have " + fmt.Sprint(rep) + ")") return } taskMob := getPlayerFlagString(p, "assassin_task_mob") if taskMob == "" { sess.WriteLine("You don't have an active task to extend.") return } setPlayerFlag(p, "assassin_reputation", rep-30) // Add 50% more kills total := getPlayerFlagInt(p, "assassin_task_total") remaining := getPlayerFlagInt(p, "assassin_task_remaining") extension := total / 2 if extension < 5 { extension = 5 } setPlayerFlag(p, "assassin_task_total", total+extension) setPlayerFlag(p, "assassin_task_remaining", remaining+extension) g.AccountStore.SaveCharacter(p) def, _ := g.MobStore.LoadDef(taskMob) name := taskMob if def != nil { name = def.Name } sess.WriteLine(fmt.Sprintf("Task extended by %d. Kill %d more %ss (%d total).", extension, remaining+extension, name, total+extension)) } func streakBonus(streak int) int { bonus := 0 if streak%1000 == 0 { bonus += 50 } else if streak%250 == 0 { bonus += 35 } else if streak%100 == 0 { bonus += 25 } else if streak%50 == 0 { bonus += 15 } else if streak%10 == 0 { bonus += 5 } return bonus } ``` --- ## 8. Reputation System ### Earning Reputation Reputation is earned on task completion: | Event | Reputation | |---|---| | Task completed (base) | +1 | | Every 10th consecutive task | +5 bonus | | Every 50th consecutive task | +15 bonus | | Every 100th consecutive task | +25 bonus | | Every 250th consecutive task | +35 bonus | | Every 1000th consecutive task | +50 bonus | Milestone bonuses are checked via modulo and are **mutually exclusive** — only the highest applicable milestone bonus is awarded per completion. The `streakBonus()` function checks from highest to lowest: ```go func streakBonus(streak int) int { if streak%1000 == 0 { return 50 } if streak%250 == 0 { return 35 } if streak%100 == 0 { return 25 } if streak%50 == 0 { return 15 } if streak%10 == 0 { return 5 } return 0 } ``` ### Spending Reputation Reputation is spent via The Client's talk dialog. The `cost` field on `NodeAction` already handles credit deduction. For reputation, since it's stored as a player flag rather than `p.Credits`, we need one of: **Option A:** Add a `reputation_cost` field to `NodeAction`. In `applyNodeAction`, deduct from the player flag. **Option B:** Handle reputation costs inside `skipAssassinTask()` and `extendAssassinTask()` (the Go functions called by `skip_task`/`extend_task` actions). **Recommended: Option B** for skip/extend (since the Go function already exists), and **Option A** for shop purchases. Add to `NodeAction`: ```go type NodeAction struct { // ... existing fields ... AssignTask bool `yaml:"assign_task"` SkipTask bool `yaml:"skip_task"` ExtendTask bool `yaml:"extend_task"` ReputationCost int `yaml:"reputation_cost"` } ``` In `applyNodeAction()`: ```go if na.ReputationCost > 0 { rep := getPlayerFlagInt(p, "assassin_reputation") if rep < na.ReputationCost { sess.WriteLine(fmt.Sprintf("You don't have enough Reputation. (Need %d, have %d)", na.ReputationCost, rep)) return // Don't apply remaining actions } setPlayerFlag(p, "assassin_reputation", rep-na.ReputationCost) g.AccountStore.SaveCharacter(p) } ``` The reputation shop nodes in the behavior YAML then use: ```yaml buy_auto_salt: message: "\"Auto-salt purchased. You'll no longer consume salt on finishing blows.\"" action: reputation_cost: 200 set_player_flags: assassin_unlocked_auto_salt: true options: - text: "\"Thanks.\"" goto: rep_shop ``` Condition to show shop items only if not already purchased: ```yaml - text: "\"Auto-finish: Salt (200 Rep)\"" goto: buy_auto_salt condition: all_of: - player_flag: assassin_unlocked_auto_salt not: true ``` --- ## 9. Reputation Shop Items | Item | Cost | Flag Set | Effect | |---|---|---|---| | Skip task | 30 Rep | (handled by `skip_task` action) | Cancels current task, resets streak | | Extend task | 30 Rep | (handled by `extend_task` action) | Adds 50% more kills to current task | | Auto-finish: Salt | 200 Rep | `assassin_unlocked_auto_salt` | Salt is not consumed on finishing blows | | Auto-finish: Acid Vial | 200 Rep | `assassin_unlocked_auto_acid_vial` | Acid vials are not consumed on finishing blows | | Unlock Superior Mobs | 300 Rep | `assassin_unlocked_superiors` | Rare chance for stronger mob variant to spawn (future feature) | | Unlock Extended Tasks | 100 Rep | `assassin_unlocked_extend` | Allows the "extend task" dialog option | | Broader Task List | 150 Rep | `assassin_unlocked_broader` | Unlocks additional mobs in the task table (future feature) | ### Updated Dialog YAML with Conditions The "extend" option should only appear if the player has purchased the unlock: ```yaml - text: "\"I want to extend my task.\"" goto: extend_confirm condition: all_of: - player_flag: assassin_task_mob not: true - player_flag: assassin_unlocked_extend value: true ``` --- ## 10. Slayer-Only Mobs ### `data/mobs/slug.yaml` ```yaml id: slug name: slug description: "A bloated, translucent slug the size of a dog. Its skin glistens with toxic mucus. It cannot be killed by conventional means — only salt can destroy it." assassin_level: 1 finishing_blow: salt attack: 3 strength: 3 defense: 1 hp: 15 speed: 6 aggressive: false respawn_ticks: 25 idle_descriptions: - "oozes along the floor leaving a slimy trail" - "contracts and expands rhythmically" - "extends its eyestalks toward you" - "secretes a glob of toxic mucus" combat_descriptions: - "lunges slimily at %s" - "sprays mucus toward %s" - "writhes in combat with %s" drops: remains: slug_mucus loot: - item_id: credits weight: 100 quantity: 15 ``` ### `data/mobs/drone.yaml` ```yaml id: drone name: drone description: "A malfunctioning security drone crackling with electrical discharge. Its attacks are especially dangerous to anyone not wearing insulated gloves." assassin_level: 15 damage_without: insulated_gloves attack: 15 strength: 14 defense: 12 hp: 45 speed: 4 aggressive: true respawn_ticks: 35 idle_descriptions: - "hovers erratically, sparking" - "emits a high-pitched whine" - "scans the area with a flickering red beam" - "rotates its weapon array with a mechanical click" combat_descriptions: - "fires an electrical bolt at %s" - "charges its capacitors and zaps %s" - "swoops down on %s with crackling energy" drops: remains: circuit_board loot: - item_id: credits weight: 80 quantity: 75 - item_id: insulated_gloves weight: 5 - item_id: credits weight: 15 quantity: 200 ``` ### `data/mobs/crawler.yaml` ```yaml id: crawler name: crawler description: "A heavily armored bio-mechanical creature with a chitinous exoskeleton. Its regenerative biology prevents death unless dissolved with acid." assassin_level: 30 finishing_blow: acid_vial attack: 25 strength: 22 defense: 30 hp: 80 speed: 5 aggressive: false respawn_ticks: 45 idle_descriptions: - "scrapes its mandibles together menacingly" - "clicks and chitters in an alien rhythm" - "tests the air with feathered antennae" - "coils its segmented body defensively" combat_descriptions: - "snaps its mandibles at %s" - "lashes out with a barbed tail at %s" - "charges headlong into %s" drops: remains: chitin_plate loot: - item_id: credits weight: 70 quantity: 250 - item_id: acid_vial weight: 10 - item_id: credits weight: 20 quantity: 500 ``` ### `data/mobs/phantom.yaml` ```yaml id: phantom name: phantom description: "A semi-transparent entity that phases in and out of visible light. Its psychic attacks are devastating to anyone without a spectral visor." assassin_level: 45 damage_without: spectral_visor attack: 35 strength: 30 defense: 25 hp: 100 speed: 3 aggressive: true respawn_ticks: 50 idle_descriptions: - "flickers between visible and invisible" - "emits a low, resonant hum" - "drifts through a wall and back again" - "stares at you with hollow, glowing eyes" combat_descriptions: - "blasts %s with a psychic wave" - "phases through %s's defenses" - "unleashes a spectral shriek at %s" drops: remains: ectoplasm loot: - item_id: credits weight: 60 quantity: 500 - item_id: spectral_visor weight: 3 - item_id: credits weight: 37 quantity: 1000 ``` ### Additional Item Drops (remain items) These are guaranteed drops ("remains") from the new mobs. Create simple item YAMLs: #### `data/items/slug_mucus.yaml` ```yaml id: slug_mucus name: slug mucus color: "82" description: "A glob of toxic slug mucus. Unpleasant." value: 5 stackable: false ``` #### `data/items/circuit_board.yaml` ```yaml id: circuit_board name: circuit board color: "40" description: "A scorched circuit board salvaged from a destroyed drone." value: 25 stackable: false ``` #### `data/items/chitin_plate.yaml` ```yaml id: chitin_plate name: chitin plate color: "130" description: "A thick plate of biological armor from a crawler's exoskeleton." value: 50 stackable: false ``` #### `data/items/ectoplasm.yaml` ```yaml id: ectoplasm name: ectoplasm color: "159" description: "A shimmering residue left behind by a destroyed phantom." value: 75 stackable: false ``` --- ## 11. Assassin Equipment ### `data/items/salt.yaml` ```yaml id: salt name: salt color: "255" description: "A packet of coarse industrial salt. Used to destroy slugs." value: 5 stackable: true ``` ### `data/items/acid_vial.yaml` ```yaml id: acid_vial name: acid vial color: "46" description: "A small vial of concentrated acid. Used to dissolve crawlers." value: 10 stackable: true ``` ### `data/items/insulated_gloves.yaml` ```yaml id: insulated_gloves name: insulated gloves color: "214" description: "Heavy rubber gloves that protect against electrical attacks. Essential when fighting drones." value: 50 stackable: false equip_slot: hands stats: defense_bonus: 1 ``` ### `data/items/spectral_visor.yaml` ```yaml id: spectral_visor name: spectral visor color: "141" description: "A visor fitted with spectral frequency filters. Dampens psychic attacks from phantoms." value: 75 stackable: false equip_slot: head stats: defense_bonus: 2 ``` --- ## 12. Finishing Blow Mechanic ### Core Logic The finishing blow mechanic has two components: #### a) HP Floor (in `playerAttack`) In `internal/game/cmd_attack.go`, in the `playerAttack()` method, after `mob.HP -= dmg` (line 199): ```go mob.HP -= dmg if mob.HP < 0 { mob.HP = 0 } // Finishing blow: mob cannot die from normal combat if mob.FinishingBlow != "" && mob.HP <= 0 { mob.HP = 1 } ``` When mob is at 1 HP and has a finishing blow, display a message: ```go if mob.FinishingBlow != "" && mob.HP == 1 { fbDef, _ := g.ItemStore.Load(mob.FinishingBlow) fbName := mob.FinishingBlow if fbDef != nil { fbName = fbDef.Name } sess.WriteLine(g.colorize(sess, "warning", fmt.Sprintf(" %s resists death! Use %s on it to finish it off.", mobDisplayName(mob, false), fbName))) } ``` #### b) `use on ` Kill (in `doUse` or new handler) The `doUse()` function in `internal/game/cmd_use.go` parses `use on `. Currently it only finds objects. The modification adds mob lookup: After the existing `use` parsing extracts `itemName` and `targetName`, before the object lookup: ```go // Check for finishing blow on mob in combat cs := combat.GetCombat(p.Name) if cs != nil { mob := g.MobStore.GetInstance(cs.MobID) if mob != nil && mob.FinishingBlow != "" && mob.HP == 1 { // Check if target matches this mob if mob.MatchQuality(targetName) != world.MatchNone { g.doFinishingBlow(sess, p, mob, itemName) return } } } ``` The `doFinishingBlow` function: ```go func (g *Game) doFinishingBlow(sess *net.Session, p *player.Player, mob *world.MobInstance, itemInput string) { // Verify the item matches the required finishing blow item fbDef, err := g.ItemStore.Load(mob.FinishingBlow) if err != nil { sess.WriteLine("Something went wrong.") return } if !fbDef.MatchesName(itemInput) { sess.WriteLine(fmt.Sprintf("That won't work on %s. You need %s.", mobDisplayName(mob, true), fbDef.Name)) return } if !p.HasItem(mob.FinishingBlow) { sess.WriteLine(fmt.Sprintf("You don't have any %s.", fbDef.Name)) return } // Check for auto-finish unlock autoKey := "assassin_unlocked_auto_" + mob.FinishingBlow if p.Flags != nil { if val, ok := p.Flags[autoKey]; ok { if b, ok := val.(bool); ok && b { // Don't consume } else { p.RemoveItem(mob.FinishingBlow, 1) } } else { p.RemoveItem(mob.FinishingBlow, 1) } } else { p.RemoveItem(mob.FinishingBlow, 1) } sess.WriteLine(fmt.Sprintf("\nYou use the %s on %s!", g.itemColorize(sess, fbDef, fbDef.Name), g.colorize(sess, "mob_name", mobDisplayName(mob, true)))) mob.HP = 0 g.endCombat(sess, p, mob) } ``` ### Combat Continues While Mob is at 1 HP The mob still attacks the player while at 1 HP. The player's attacks continue hitting (and show damage) but the mob HP stays at 1. The player must type `use salt on slug` (or similar) during combat to kill it. This is handled because: 1. The combat tick subscriber keeps running (mob HP > 0 since it's clamped to 1) 2. Player attacks deal damage but HP is floored at 1 3. `use` command is classified as `ClassActive` — it replaces the current active action. However, this creates a conflict because the player is in combat. **Problem:** `StartAction()` checks `combat.GetCombat(p.Name) != nil` and returns "You can't do that during combat!" (line 50-53 of action.go). **Solution:** The finishing blow `use` should NOT route through `StartAction()`. Instead, handle it directly in `executeCommand()` under the `"use"` case, BEFORE calling `doUse()`: In `executeCommand()`, the `"use"` case (line 367-369): ```go case "use": // Check for finishing blow first if g.tryFinishingBlow(sess, strings.Join(args, " ")) { return } g.doUse(sess, strings.Join(args, " ")) return ``` The `tryFinishingBlow` function: ```go func (g *Game) tryFinishingBlow(sess *net.Session, input string) bool { p := sess.Player.(*player.Player) cs := combat.GetCombat(p.Name) if cs == nil { return false } mob := g.MobStore.GetInstance(cs.MobID) if mob == nil || mob.FinishingBlow == "" || mob.HP != 1 { return false } // Parse "item on target" or "item on/with target" lower := strings.ToLower(input) var itemPart, targetPart string for _, sep := range []string{" on ", " with "} { if idx := strings.Index(lower, sep); idx > 0 { itemPart = strings.TrimSpace(input[:idx]) targetPart = strings.TrimSpace(input[idx+len(sep):]) break } } if targetPart == "" { return false } if mob.MatchQuality(targetPart) == world.MatchNone { return false } g.doFinishingBlow(sess, p, mob, itemPart) return true } ``` --- ## 13. Damage Reduction Equipment ### Mechanic Mobs with a `damage_without` field deal 1.5x damage when the player does NOT have the specified item equipped. This simulates the "protect yourself" mechanic from Slayer. ### Implementation in `mobAttack()` In `internal/game/cmd_attack.go`, in the `mobAttack()` function, after computing `dmg` from `combat.RollDamage(maxHit)` (around line 254): ```go if combat.HitCheck(attRoll, defRoll) { maxHit := combat.MaxHit(mob.Strength, 0, 0) dmg := combat.RollDamage(maxHit) // Damage amplification if player lacks protective equipment if mob.DamageWithout != "" { hasProtection := false for _, itemID := range p.Equipment { if itemID == mob.DamageWithout { hasProtection = true break } } if !hasProtection { dmg = dmg * 3 / 2 if dmg < 1 { dmg = 1 } } } p.HP -= dmg // ... rest of existing code } ``` ### Player Feedback Optionally, the first time in a combat session the player takes amplified damage, display a warning: ```go if !hasProtection { dmg = dmg * 3 / 2 fbDef, _ := g.ItemStore.Load(mob.DamageWithout) fbName := mob.DamageWithout if fbDef != nil { fbName = fbDef.Name } // Only warn once per combat (use a flag on combat state or session) sess.WriteLine(g.colorize(sess, "warning", fmt.Sprintf(" %s's attack is extra effective! Equip %s for protection.", attacker, fbName))) } ``` To avoid spamming this every hit, track whether the warning has been shown. The simplest approach: add a `DamageWarningShown` bool field to `combat.State`: ```go type State struct { PlayerName string MobID string Active bool DamageWarningShown bool } ``` Then in `mobAttack()`: ```go if !hasProtection { dmg = dmg * 3 / 2 if !cs.DamageWarningShown { cs.DamageWarningShown = true // ... display warning } } ``` --- ## 14. XP Calculation ### Assassin XP Per Kill (On-Task Only) Formula: `assassinXP = mob.MaxHP * 2` This is awarded ONLY when the killed mob matches the player's current task (`assassin_task_mob` player flag). XP is awarded in `onAssassinKill()` which is called from `endCombat()` after the mob death is confirmed. ### Examples | Mob | MaxHP | Assassin XP Per Kill | |---|---|---| | slug | 15 | 30 | | drone | 45 | 90 | | crawler | 80 | 160 | | phantom | 100 | 200 | | man | 7 | 14 | | cow | 8 | 16 | ### XP Table Reference The game uses the RSC XP table (defined in `internal/player/xp.go` or similar). Level 1 = 0 XP, Level 2 = 83 XP, etc. The same table applies to Assassin. ### Normal Combat XP Still Awarded The existing `awardCombatXP()` function is not modified. Players receive both normal combat XP (Attack/Strength/Defense/Hitpoints based on style) AND Assassin XP when on-task. --- ## 15. Mob YAML Extensions ### New Fields on `MobDef` (in `internal/world/mob.go`) ```go type MobDef struct { ID string `yaml:"id"` Name string `yaml:"name"` Description string `yaml:"description"` BehaviorID string `yaml:"behavior"` IdleDescriptions []string `yaml:"idle_descriptions"` CombatDescriptions []string `yaml:"combat_descriptions"` Attack int `yaml:"attack"` Strength int `yaml:"strength"` Defense int `yaml:"defense"` HP int `yaml:"hp"` Speed float64 `yaml:"speed"` Aggressive bool `yaml:"aggressive"` Protected bool `yaml:"protected"` Unique bool `yaml:"unique"` RespawnTicks float64 `yaml:"respawn_ticks"` Drops DropTable `yaml:"drops"` // NEW FIELDS: AssassinLevel int `yaml:"assassin_level"` FinishingBlow string `yaml:"finishing_blow"` DamageWithout string `yaml:"damage_without"` } ``` ### New Fields on `MobInstance` ```go type MobInstance struct { // ... existing fields ... AssassinLevel int FinishingBlow string DamageWithout string } ``` ### Copy in `SeedMobs()` In the `SeedMobs` function (line 236-259 of `internal/world/mob.go`), add to the instance creation: ```go inst := &MobInstance{ // ... existing fields ... AssassinLevel: dw.def.AssassinLevel, FinishingBlow: dw.def.FinishingBlow, DamageWithout: dw.def.DamageWithout, } ``` ### Mobs Without These Fields Mobs that don't have these fields in their YAML will have zero-value defaults: - `AssassinLevel: 0` — no requirement to attack - `FinishingBlow: ""` — no finishing blow needed - `DamageWithout: ""` — no extra damage This is fully backward compatible. --- ## 16. Rooms ### The Client's Location Create a new room for The Client. Since room 19 is currently a stub ("Agility Course"), use a different room number. Suggested: Room 50+ area for the "Assassin District." #### `data/rooms/50.yaml` — Assassin Den ```yaml id: 50 name: "The Assassin's Den" description: "A dimly lit basement accessible through a trapdoor. Tactical maps and bounty posters line the walls. The air smells of gun oil and burnt ozone. {141}The Client{/} sits behind a reinforced desk." exits: up: 1 mobs: - "client" ``` Connect from Town Square (room 1) by adding `down: 50` exit (or use a different connection point). Room 1 already has `down: 31`, so either: - Add the den as a separate entrance from another room - Create a chain: Room 1 → Room 31 → Room 50 Alternative: connect from an existing room that has a free exit direction. ### Slayer-Only Mob Rooms #### `data/rooms/51.yaml` — Sewer Tunnels ```yaml id: 51 name: "Sewer Tunnels" description: "Dark, damp tunnels beneath the settlement. The floor is slick with moisture and something {82}slimy{/}. The smell is indescribable." exits: north: 50 south: 52 mobs: - "slug" - "slug" - "slug" ``` #### `data/rooms/52.yaml` — Abandoned Sector ```yaml id: 52 name: "Abandoned Sector" description: "A decommissioned sector of the asteroid's infrastructure. Broken monitors flicker and exposed wiring {214}sparks{/} dangerously." exits: north: 51 south: 53 mobs: - "drone" - "drone" ``` #### `data/rooms/53.yaml` — Deep Tunnels ```yaml id: 53 name: "Deep Tunnels" description: "The tunnels descend deeper into the asteroid's core. Strange chitinous scraping echoes from the darkness. The walls are scarred with {130}claw marks{/}." exits: north: 52 south: 54 mobs: - "crawler" - "crawler" ``` #### `data/rooms/54.yaml` — The Void Chamber ```yaml id: 54 name: "The Void Chamber" description: "A vast cavern where reality seems to thin. The air shimmers with {141}spectral energy{/} and strange whispers fill your mind." exits: north: 53 mobs: - "phantom" - "phantom" ``` ### Connecting to Existing World Add an exit from the Assassin Den (room 50) back to an appropriate existing room. The den should connect to somewhere in the 1-30 range. Example: add `down: 50` to room 22 (west of town square) or create a new connection. Update the connecting room's YAML to add the exit: ```yaml # In the connecting room, add: exits: down: 50 # To Assassin Den ``` --- ## 17. Help Files ### `data/help/task.yaml` ```yaml name: "task" category: "Assassin" description: | Check your current Assassin task status. Usage: task Displays your current task target, kills remaining, streak count, and unspent Reputation points. If you have no active task, visit The Client to receive a new assignment. The Client can be found in The Assassin's Den, accessible from the lower levels of the settlement. ``` ### `data/help/assassin.yaml` ```yaml name: "assassin" category: "Skills" description: | The Assassin skill (Slayer equivalent). Talk to The Client in The Assassin's Den to receive tasks. Each task assigns you a number of specific mobs to kill. Killing mobs on-task awards Assassin XP (mob's max HP x 2) in addition to normal combat XP. Some mobs require a minimum Assassin level to attack: Slug - Level 1 (needs salt to finish off) Drone - Level 15 (extra damage without insulated gloves) Crawler - Level 30 (needs acid vial to finish off) Phantom - Level 45 (extra damage without spectral visor) Finishing Blow: Some mobs cannot be killed below 1 HP. Use the required item on them during combat: "use salt on slug" Protection: Some mobs deal 1.5x damage unless you have the required protective item equipped. Reputation is earned on task completion (1 per task + streak bonuses at 10th, 50th, 100th, 250th, and 1000th consecutive tasks). Spend Reputation at The Client's Reputation Shop for permanent unlocks and task management options. Related: task, attack, use ``` --- ## 18. Task Table The full task assignment table. Each entry specifies: - `MobID`: The mob definition to assign - `MinLevel` / `MaxLevel`: Player's Assassin level range for eligibility - `MinCount` / `MaxCount`: Random kill count range - `Weight`: Relative probability of being assigned ### Starter Tasks (Assassin Level 1+) | MobID | Min Lvl | Max Lvl | Min Count | Max Count | Weight | Notes | |---|---|---|---|---|---|---| | `man` | 1 | 15 | 10 | 25 | 8 | Basic melee mob, 7 HP | | `cow` | 1 | 15 | 10 | 25 | 8 | Basic melee mob, 8 HP | | `slug` | 1 | 99 | 15 | 45 | 15 | Finishing blow: salt | ### Mid-Level Tasks (Assassin Level 15+) | MobID | Min Lvl | Max Lvl | Min Count | Max Count | Weight | Notes | |---|---|---|---|---|---|---| | `drone` | 15 | 99 | 20 | 50 | 12 | Damage without: insulated_gloves | ### High-Level Tasks (Assassin Level 30+) | MobID | Min Lvl | Max Lvl | Min Count | Max Count | Weight | Notes | |---|---|---|---|---|---|---| | `crawler` | 30 | 99 | 15 | 40 | 10 | Finishing blow: acid_vial | ### Expert Tasks (Assassin Level 45+) | MobID | Min Lvl | Max Lvl | Min Count | Max Count | Weight | Notes | |---|---|---|---|---|---|---| | `phantom` | 45 | 99 | 10 | 30 | 8 | Damage without: spectral_visor | ### Task Table in Go (Full) ```go var assassinTaskTable = []assassinTaskEntry{ // Starter mobs — available early, phased out at mid levels {"man", 1, 15, 10, 25, 8}, {"cow", 1, 15, 10, 25, 8}, // Core assassin mobs — available from their assassin_level onward {"slug", 1, 99, 15, 45, 15}, {"drone", 15, 99, 20, 50, 12}, {"crawler", 30, 99, 15, 40, 10}, {"phantom", 45, 99, 10, 30, 8}, } ``` ### Expanding the Table As new mobs are added to the game, add entries to `assassinTaskTable`. The weighted random system ensures new mobs can be introduced without modifying existing entries. Future mobs behind the `assassin_unlocked_broader` perk can be filtered in `assignAssassinTask()`: ```go for _, entry := range assassinTaskTable { if level >= entry.MinLevel && level <= entry.MaxLevel { if entry.RequiresUnlock != "" { if p.Flags == nil { continue } if val, ok := p.Flags[entry.RequiresUnlock]; !ok || val != true { continue } } eligible = append(eligible, entry) totalWeight += entry.Weight } } ``` Add `RequiresUnlock string` to `assassinTaskEntry` for this. --- ## Summary of All File Changes ### New Go Files 1. `internal/game/cmd_task.go` — `doTask()` command handler 2. `internal/game/assassin.go` — Task table, assignment, on-kill processing, flag helpers, finishing blow, skip/extend ### Modified Go Files 1. `internal/world/mob.go` — Add `AssassinLevel`, `FinishingBlow`, `DamageWithout` to `MobDef` and `MobInstance`; copy in `SeedMobs()` 2. `internal/game/cmd_attack.go` — Assassin level check in `doAttack()`, HP floor in `playerAttack()`, damage amplification in `mobAttack()`, `onAssassinKill()` call in `endCombat()` 3. `internal/game/game.go` — Add `"task"` to `classifyCommand()` Instant list; add `"task"` case in `executeCommand()`; add finishing blow intercept before `doUse()` 4. `internal/action/behavior.go` — Add `AssignTask`, `SkipTask`, `ExtendTask`, `ReputationCost` to `NodeAction` 5. `internal/game/action_talk.go` — Handle new `NodeAction` fields in `applyNodeAction()` 6. `internal/combat/state.go` — Add `DamageWarningShown` to `State` (optional) ### New YAML Files 1. `data/mobs/slug.yaml` 2. `data/mobs/drone.yaml` 3. `data/mobs/crawler.yaml` 4. `data/mobs/phantom.yaml` 5. `data/mobs/client.yaml` 6. `data/items/salt.yaml` 7. `data/items/acid_vial.yaml` 8. `data/items/insulated_gloves.yaml` 9. `data/items/spectral_visor.yaml` 10. `data/items/slug_mucus.yaml` 11. `data/items/circuit_board.yaml` 12. `data/items/chitin_plate.yaml` 13. `data/items/ectoplasm.yaml` 14. `data/behaviors/client_talk.yaml` 15. `data/help/task.yaml` 16. `data/help/assassin.yaml` 17. `data/rooms/50.yaml` 18. `data/rooms/51.yaml` 19. `data/rooms/52.yaml` 20. `data/rooms/53.yaml` 21. `data/rooms/54.yaml` ### Modified YAML Files 1. An existing room YAML (e.g. room 22 or room 31) — add exit to room 50 --- ## Implementation Order 1. **MobDef extensions** (`internal/world/mob.go`) — Add fields, update `SeedMobs()` 2. **NodeAction extensions** (`internal/action/behavior.go`) — Add action fields 3. **Flag helpers** (`internal/game/assassin.go`) — `getPlayerFlagInt`, `getPlayerFlagString`, `setPlayerFlag` 4. **Task table and assignment** (`internal/game/assassin.go`) — `assassinTaskTable`, `assignAssassinTask()` 5. **On-kill processing** (`internal/game/assassin.go`) — `onAssassinKill()`, `streakBonus()` 6. **Skip/extend** (`internal/game/assassin.go`) — `skipAssassinTask()`, `extendAssassinTask()` 7. **Task command** (`internal/game/cmd_task.go`) — `doTask()` 8. **Register command** (`internal/game/game.go`) — `classifyCommand()`, `executeCommand()` 9. **Attack modifications** (`internal/game/cmd_attack.go`) — Level check, HP floor, damage amp, on-kill call 10. **Finishing blow via use** (`internal/game/cmd_attack.go` or `assassin.go`) — `tryFinishingBlow()`, `doFinishingBlow()` 11. **Talk action extensions** (`internal/game/action_talk.go`) — Handle `assign_task`, `skip_task`, `extend_task`, `reputation_cost` 12. **All YAML data files** — Mobs, items, behaviors, rooms, help 13. **Connect rooms** — Update existing room YAML to link to room 50 14. **Test** — `make test`, `make vet`, manual play testing --- ## Testing Checklist - [ ] `make build` succeeds - [ ] `make vet` passes - [ ] `make test` passes - [ ] Can talk to The Client and receive a task - [ ] `task` command shows current task info - [ ] Killing on-task mobs awards Assassin XP and decrements remaining count - [ ] Killing off-task mobs does NOT award Assassin XP - [ ] Task completion awards reputation and increments streak - [ ] Streak bonuses at 10th, 50th milestones - [ ] Skip task costs 30 rep and resets streak - [ ] Extend task costs 30 rep and adds kills - [ ] Cannot attack mobs with `assassin_level` higher than player's level - [ ] Slug stays at 1 HP from normal attacks - [ ] `use salt on slug` kills the slug - [ ] Salt is consumed on finishing blow (unless auto-finish unlocked) - [ ] Drone deals 1.5x damage without insulated gloves equipped - [ ] Drone deals normal damage with insulated gloves equipped - [ ] Reputation shop purchases set correct player flags - [ ] Auto-finish unlock prevents salt/acid consumption - [ ] Items sold by Client via talk dialog work correctly - [ ] Help topics display correctly - [ ] New rooms are navigable and mobs spawn correctly - [ ] Mob remains items drop correctly - [ ] Backward compatibility — existing mobs with no assassin fields work normally