aboutsummaryrefslogtreecommitdiff
path: root/skill_plans/assassin.md
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-06-19 18:33:43 -0400
committerhistoria <[not public]>2026-06-19 18:33:43 -0400
commit2bec24859af8f03c80a0a58e3240519942450167 (patch)
treedcb8baa5591fdeca51e179a63a08d46b014cb85c /skill_plans/assassin.md
parent0ea4eec5dd2122c6704216971eb1249276297867 (diff)
downloadthehouseoficarus-2bec24859af8f03c80a0a58e3240519942450167.tar.gz
readme
Diffstat (limited to 'skill_plans/assassin.md')
-rw-r--r--skill_plans/assassin.md1895
1 files changed, 0 insertions, 1895 deletions
diff --git a/skill_plans/assassin.md b/skill_plans/assassin.md
deleted file mode 100644
index 31ea301..0000000
--- a/skill_plans/assassin.md
+++ /dev/null
@@ -1,1895 +0,0 @@
-# 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_<perk>` | `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 <item> on <mob>` 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 <item> on <target>` 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 <item> on <mob>` Kill (in `doUse` or new handler)
-
-The `doUse()` function in `internal/game/cmd_use.go` parses `use <item> on <target>`. 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