This is the engineering reference for the naval subsystem: player-owned
vessels, NPC pirates and hunters, sea combat, the cargo market, and how ships
are persisted. For commands, the hull/weapon/crew catalogues, prices and the
trade economy, see SHIP_GAMEPLAY.md. Both documents were
written from the source at commit 2209da668 (2026-09-27). Where the code and
older text disagree, the code wins and the discrepancy is listed under
Known issues and discrepancies.
The source files carry detailed OVERVIEW comments. This document collects them
into a single picture and adds the numbers and cross-module paths that the
comments leave out. Start with src/ships/ships.h, then ship_utils.c, then
ship_base.c.
Ferries (src/world/ferryact.c) are a separate, scripted transport system. They
share the word "ship" and the ITEM_SHIP object type, but none of the code
described here.
Contents
- Source map
- Core concepts
- Lifecycle
- The heartbeat
- Movement model
- Crew model
- Combat
- Flying ships
- Ocean PvP state
- NPC ships
- Player autopilot
- Persistence
- GMCP
- Integration with the rest of the game
- Extending the system
- Tests
- Known issues and discrepancies
Source map
| File | Responsibility |
|---|---|
src/ships/ships.h |
Public data model: ShipData, ShipSlot, ShipCrew, the static-table structs, flags, constants, SHIP_*() accessor macros, the ShipObjHash registry, and every cross-module prototype. |
src/ships/ship_variables.c |
Static data only: ports, crews, chiefs, weapons, equipment, hull classes, per-arc properties, mount permissions, tactical-map symbols. |
src/ships/ship_utils.c |
Registry (shipObjHash), tactical map and contact list, geometry, speed/turn maths, crew mechanics, slot helpers, messaging, jettison/salvage, ocean_pvp_state(). |
src/ships/ship_base.c |
Lifecycle (create, name, lay out rooms, load, delete), the per-tick ship_activity(), docking, crashing, sinking completion, flying, summoning arrival, the save queue and both persistence backends' glue, the frag leaderboard. |
src/ships/ship_control.c |
Bridge commands. ship_panel_proc() dispatches order, fire, lock, scan, look and get money. Mostly input validation that calls into other modules. |
src/ships/ship_combat.c |
Hit chance (weaponsight()), firing, delayed volleys, sail/hull/weapon damage, update_ship_status(), sinking and reward settlement, ramming, the lookout report. |
src/ships/ship_cargo.c |
Cargo and contraband prices, the three market matrices, market drift, market persistence (SQL, flat file, maintenance snapshot), the immortal world cargo command. |
src/ships/ship_shop.c |
The shipwright (ship_shop_proc()) and crew hall (crew_shop_proc()), hull purchase through the epic-transaction service, repairs, reloads, customs (check_contraband()), and the automatons quest. |
src/ships/ship_auto.c, ship_auto.h |
Player autopilot (order sail). Also holds dormant fleet/"ship group" code. |
src/ships/ship_npc.c, ship_npc.h |
NPC content: pirate names, 45 fit-out rows, spawn logic, crew rosters, treasure, Cyric's Revenge, the disabled zone ship. |
src/ships/ship_npc_ai.c, ship_npc_ai.h |
The NPCShipAI state machine: target selection, basic and advanced combat manoeuvring, ramming, boarding, looting, escorting, despawn. |
src/ships/ship_identity.c |
Process-local ShipRuntimeRef (slot, generation) handles, so delayed events survive ship deletion. |
Integration points outside src/ships/:
| Location | What it does with ships |
|---|---|
src/world/db.c |
Calls initialize_ships() during world boot. |
src/net/comm.c |
Runs ship_activity() once per second, and the GMCP flush plus flush_pending_ship_saves() every 2 pulses. It also calls shutdown_ships() on orderly shutdown and applies cargo maintenance completions. |
src/persistence/copyover.c |
Drains pending ship saves before copyover and aborts the copyover if any save cannot be made durable. |
src/persistence/maintenance_*.c |
Drives the periodic cargo-market update (job cargo_market). |
src/sql/sql_player.c |
SQL backend: sql_save_ship(), sql_load_all_ships(), sql_place_ship(), sql_delete_ship(). |
src/flatfile/flatfile_ship_repository.c |
Flat-file backend: the ship_catalog record and the legacy Ships/ importer. |
src/redis/redis_ship_legacy.c |
Asynchronous invalidation of retired ship:snapshot:* Redis keys. |
src/net/gmcp.c, src/core/json_utils.c |
Ship.Contacts and Ship.Info packages. |
src/specs/specs.assign.c |
Attaches ship_shop_proc, crew_shop_proc, erzul_proc and moonstone_fragment to their rooms, mobs and objects. |
src/core/files.c, src/account/account.c |
Delete a character's ship with the character or account. |
src/net/modify.c |
Character renames move ship ownership. Immortal rename ship. |
src/cmd/actwiz.c, src/cmd/actinf.c |
Immortal set ship ... and world cargo .... |
src/specs/specs.gellz.c |
Immortal test object 1203: `say ship all |
src/combat/fraglist.c, src/classes/drannak.c, src/world/hardcore.c |
Ship frag leaderboard and hardcore scoring. |
src/world/achievements.c |
Trader achievement (quick build) and the Sailor's Tattoo free-hull reward. |
Core concepts
Three identities
A ship has three unrelated identifiers. Do not mix them up:
| Identity | Field | Scope | Meaning |
|---|---|---|---|
| Database id | ShipData::db_id |
Durable | Row id in ships (SQL) or ship_id in the flat-file catalog. -1 until first saved. NPC ships never get one. |
| Contact designation | SHIP_ID() / ShipData::id |
Visible to players | Two letters shown in contacts and combat messages, used by lock, scan and signal. Player ships draw from A–W as the first letter, NPC ships from X–Z. A docked ship shows **, and a new designation is minted on every undock. |
| Runtime reference | ShipData::runtime_ref |
One process | A (slot, generation) pair from ship_identity.c. Delayed work (weapon volleys) stores this instead of a P_ship and resolves it later, which fails safely if the ship has been deleted or the slot reused. The slot number is 1-based, so 0 means no identity. |
Ownership is by name: ShipData::ownername holds the owning character's name,
and there is at most one ship per owner (ships.owner_name is UNIQUE).
get_ship_from_owner() looks a ship up by owner. get_ship_from_char() finds
the ship whose interior a character is standing in.
Indices, units and append-only tables
- Hull class (
m_class), weapon, equipment, crew and chief are stored as 0-based indices into the tables inship_variables.c, and are persisted verbatim.ShipTypeData::_classidis a separate, 1-based external id. - Never reorder or insert rows in those tables. Append, bump the matching
MAX*constant inships.h, and extend every parallel table (see Extending the system). - Sides are
SIDE_FORE,SIDE_PORT,SIDE_REAR,SIDE_STAR(0–3).SLOT_HOLD(4) andSLOT_EQUI(5) are extra slot positions for cargo and equipment. - Headings and bearings are compass degrees in
[0, 360): 0 is north, 90 east. - Crew skills are floats with a base of roughly hundreds to thousands (for example 200 for a raw pirate crew, 3000 for automatons). Both backends store them as thousandths.
- Money is in copper, and 1000 copper is 1 platinum. Prices in the tables are copper.
Time units
- One ship tick is one second.
run_activity_phase()callsship_activity()everyWAIT_SEC(4) pulses. EveryShipData::timer[]and slot timer counts ship ticks. - Shop messages report maintenance in "hours" as
timer / 75. That is a mud hour of 75 real seconds (SECS_PER_MUD_HOUR), so "installing takes 10 hours" means 12.5 real minutes. - Delayed events use the
neventscheduler, which counts pulses (quarter seconds). Volley flight time and summon travel time are in pulses.
Where a ship is
Three separate notions, and the source warns against confusing them:
ship->locationis the real room index of the ocean or harbour room the hull object sits in.ship->room[]holds the ship's interior rooms as vnums, plus the exits between them.ship->anchoris the vnum of the dock the ship last docked at. It is persisted, and the ship reappears there at boot.
Interior rooms come from a fixed pool in zone 600 (areas/wld/ship.wld, vnums
60000–64999). The first three are reserved:
| Vnum | Room | Use |
|---|---|---|
| 60000 | Davy Jones Locker | Where a sunk player ship is docked as a sloop (VROOM_DAVY_JONES). |
| 60001 | Speeding Across The Ocean | The hull waits here while summoned (VROOM_SHIP_TRANSIT). |
| 60002 | Aboard a spectral galleon | The undead ferry room. |
The remaining 4,997 rooms are handed out by find_free_ship_room(). A room is
in use while its funct is ship_room_proc, and clear_ship_layout()
returns it to the pool. The pool must stay contiguous. At 2 to 15 rooms per
hull it holds between 333 and 2,498 ships, depending on the hull mix.
set_ship_physical_layout() claims all of a ship's rooms or none: when the pool
runs out part-way, it gives back what it had claimed.
Each hull class has a fixed interior graph built by set_ship_layout(). It is
first built as slot indices, then wired to real rooms by
set_ship_physical_layout(). Slot 0 is always the bridge, where the control
panel sits. Players board and leave through the entrance slot:
| Class | Rooms | Entrance slot | Named rooms (from name_ship_rooms()) |
|---|---|---|---|
| Sloop | 2 | 1 | |
| Yacht | 3 | 2 | |
| Clipper | 4 | 3 | |
| Ketch | 4 | 2 | |
| Caravel | 7 | 5 | |
| Carrack | 8 | 7 | |
| Galleon | 10 | 8 | |
| Corvette | 5 | 2 | Launch decks 1, 3, 4 |
| Destroyer | 8 | 5 | Launch decks 1–3 |
| Frigate | 9 | 7 | Launch decks 1–3 |
| Cruiser | 12 | 9 (docking bay) | Launch decks 7, 10, 11 |
| Dreadnought | 15 | 14 (docking bay) | Hold 7, launch decks 3, 8–10 |
| Large Ship (NPC only) | 2 | 1 |
set_ship_layout() and name_ship_rooms() index rooms the same way and must be
changed together.
Objects
| Vnum | Object | Special procedure |
|---|---|---|
| 60001 | The hull (ITEM_SHIP). One copy per ship floats in ship->location. value[6] == 1 marks it as a map ship for getmap(). Its keywords are ship <class> <owner>, so enter <owner> works. |
ship_obj_proc (boarding) |
| 60000 | The control panel on the bridge. | ship_panel_proc (all bridge commands) |
| 60002 | A floating cargo crate, created by jettison or sinking and picked up by order salvage. value[0] is the commodity and value[1] is 1 for cargo or 2 for contraband. |
none |
| 1223 | Cargo information wand (immortal, must be held): look cargo, look ships. |
ship_cargo_info_stick |
ShipObjHash shipObjHash is the authoritative registry of every ship, player
and NPC. It is keyed by the hull object and capped at MAXSHIPS (2000).
Flags
ShipData::flags is persisted wholesale, so runtime bits such as LOADED,
DOCKED and SUMMONED travel through the database too. load_ship() clears
the transient ones (SINKING, FLYING, RAMMING, SUNKBYNPC,
ATTACKBYNPC and SUMMONED) and sets DOCKED and LOADED. The summon's
arrival event is not saved, so a summons in flight at a reboot or copyover is
dropped and the ship comes back at its anchor.
| Flag | Meaning |
|---|---|
LOADED |
Placed in the world. Most code skips ships without it. |
AIR |
Permanently airborne-capable hull (Cyric's Revenge). Flies without a levistone timer. |
SINKING |
Sink timer running. |
DOCKED |
Alongside in port. Safe: cannot be locked onto, fired at or rammed. |
RAMMING |
order ram armed. |
ANCHOR |
Anchored at sea. Faster repairs and stamina regeneration. |
TO_DELETE |
Deleted by initialize_ships() at boot. Nothing in the live code sets it. |
SUMMONED |
A summons is in flight. |
SUNKBYNPC |
Set by sink_ship() when an NPC made the kill. It raises insurance to 90% and softens the sinking penalty. |
ATTACKBYNPC |
A merchant already ambushed since it last docked, so it is not ambushed again. |
FLYING |
Currently in the air. |
IMMOBILE, SQUID_SHIP |
Unused. Immobility is derived (maxspeed == 0, see SHIP_IMMOBILE()). |
race records which side the ship sails for. order undock stamps it from the
captain's racewar side: GOODIESHIP, EVILSHIP, UNDEADSHIP, SQUIDSHIP
(neutral) or UNKNOWNSHIP. NPC ships are NPCSHIP. Race decides hostility,
reward eligibility, signal and the flag other ships see when they scan.
Lifecycle
stateDiagram-v2
[*] --> Created: new_ship()
Created --> Docked: name_ship() + load_ship()
Docked --> Undocking: order undock (30 ticks)
Undocking --> Underway: T_UNDOCK expires (new contact id)
Underway --> Anchored: order anchor
Anchored --> Underway: order undock (13 ticks)
Underway --> Docked: order maneuver into a dockable room
Underway --> Flying: order fly (levistone)
Flying --> Underway: timer expires / order land
Underway --> Transit: summon (not at battle stations)
Anchored --> Transit: summon
Docked --> Transit: summon
Transit --> Docked: summon_ship_event()
Transit --> Docked: reboot or copyover (load_ship() at the anchor)
Underway --> Sinking: two arcs breached
Anchored --> Sinking: two arcs breached
Sinking --> DavyJones: player ship: finish_sinking() downgrades it to a sloop
DavyJones --> Transit: summon
Sinking --> [*]: NPC ship: finish_sinking() deletes it
- Create.
new_ship(m_class)allocates the ship, loads a hull object and a panel, applies class defaults (full armour and sail, repair stock equal to hull weight, amateur crew, no chiefs,**designation, centred at map position 50,50), builds the abstract room graph and registers the runtime identity. It returnsNULLwith a code inshiperroratMAXSHIPSor when a prototype is missing. - Name.
name_ship()rebuilds the hull object's strings. The hull prototype's strings are shared by every copy, so the old pointers are dropped rather than freed. That is deliberate, not a leak. - Load.
load_ship(ship, real_room)first clearsLOADED, which is saved with the other flags. It then claims interior rooms, puts the panel on the bridge and the hull in the room, setsDOCKED | LOADED, clearsSINKING,FLYING,SUNKBYNPC,ATTACKBYNPC,RAMMINGandSUMMONED, and records the room as the anchor. If it fails, usually because the room pool is full, every caller erases and destroys the ship. Boot keeps the stored ship and notes its owner (note_unplaced_ship()), andretry_unplaced_ships()places it about once a minute once rooms free up. A purchase is refunded or not charged. - Hull change. A hull purchase first checks
ship_rooms_fit_class(): the rooms the ship holds, whichreset_ship()gives back, plus the free rooms in the pool must cover the new layout. If they do not, the purchase is refused before the epic transaction starts, or refunded if the pool filled up while it was pending. A first-ship purchase by someone whose stored ship is out of the world brings that ship back instead (place_stored_ship()), or is refused while there is no room, because a second ship could never be saved beside it. - Undock.
order undockchecks the sails, mobility andcheck_undocking_conditions(): a legal name, weapons allowed on the hull, per-arc mount and weight limits, and the captain's level at least the hull's minimum. It setsraceand startsT_UNDOCK(30 ticks, or 2 for an immortal).ship_activity()narrates the countdown and, at tick 1, assigns a fresh contact id and clearsDOCKEDandANCHOR. Weighing anchor at sea uses the same countdown but finishes when it reaches 17, 13 ticks in, and skips the undocking checks. - Dock.
dock_ship()records the new anchor (queues a save), refills the repair stock to hull weight, resets the designation to**, drops all target locks on and by the ship, clearsATTACKBYNPCand restores crew stamina. - Sink.
update_ship_status()callssink_ship()when two arcs are breached.finish_sinking()runs whenT_SINKINGexpires (see Sinking and insurance). - Delete.
delete_ship()requires the caller to have erased the ship fromshipObjHash. For a player ship it deletes the durable row first and aborts (re-adding the ship to the hash) if that fails. It then releases the runtime identity and rooms, clears other ships' locks, frees the autopilot and AI, and extracts both objects. Thename,ownername,idandkeywordsstrings are knowingly leaked (see the note inname_ship()).
The heartbeat
ship_activity() runs once per second over every loaded ship, in hash order.
For each ship:
- Announce and decrement all
MAXTIMERStimers. - Regenerate crew stamina (+3 a tick, +4 for NPC ships, +15 for Cyric's Revenge, ×4 while docked or anchored) and warn when it is negative.
- Hold battle stations:
T_BSTATIONis reset to 180 ticks while a target is locked. The crew stands down 3 minutes after the lock is cleared. - Run crew repairs from the repair stock (see Crew model).
- If sinking and
T_SINKINGhas reached 0, callfinish_sinking()and return from the whole sweep. For an NPC shipfinish_sinking()frees the ship and erases it from the hash, so continuing would use a stale iterator. Ships later in the hash lose one tick when this happens. - Step the undock countdown.
- If undocked, not anchored and not sinking:
- converge
speedonsetspeedandheadingonsetheading, charging stamina (skipped while mindblasted); - move, crossing into the next map room when the fractional position leaves
[50, 51), or stop and possibly crash at a coastline; - resolve
RAMMINGwhen the target is within 1 room; - tick weapon reloads and equipment timers (paused while ramming, recovering from a ram, or mindblasted; each reload tick happens with probability equal to the stamina modifier);
- run the player autopilot and the NPC AI;
- roll for a pirate ambush.
- converge
Other ship work in the game loop:
| Cadence | Work |
|---|---|
Every 2 pulses (run_recurring_persistence_phase) |
gmcp_flush_dirty_ship_contacts(), gmcp_flush_dirty_ship_info(), flush_pending_ship_saves() |
Maintenance job cargo_market (checked every 240 pulses) |
Market drift when ship.cargo.update.secs has elapsed, and a refresh of the delayed prices when ship.cargo.updateDelayedPrices.secs has elapsed. |
Delayed nevent |
volley_hit_event() (weapon impacts), summon_ship_event() (summon arrival) |
cargo_activity() and timers_activity() still exist but nothing calls them.
The maintenance scheduler replaced that path, and
tests/async/test_maintenance_scheduler.py pins that comm.c no longer calls
timers_activity().
Movement model
Position. A ship at sea always sits in one map room. ship->x and ship->y
are its fractional position inside that room, normally in [50, 51) on the
100×100 tactical patch centred on the ship. Each tick adds
speed × sin(heading) / 150 to x and speed × cos(heading) / 150 to y. A
ship at speed 100 therefore crosses a room every 1.5 seconds, and one at speed
40 every 3.75 seconds.
Legal water. is_valid_sailing_location() accepts only rooms with vnum
≥ 110000 that are map rooms with sector SECT_OCEAN. A flying ship accepts any
map room except mountains. When the next room is illegal the ship stops dead,
re-centres at 50.5, stops its autopilot, and then may crash:
- no chance unless the crew is at battle stations;
- otherwise the chance is
(speed + 50) / ((1 + 2 × sail_mod) × stamina_mod)against2d50, with the speed the ship hit the coast at; - certain while mindblasted.
crash_land() deals hull_weight / 25 + 1 potential hits of 1–9 damage. The
first always lands on the fore arc; each of the rest has a 50% chance and picks
a random arc or the sails.
Harbours. order maneuver <n|e|s|w> moves one room at a time where sailing
is not allowed. A move is permitted only when the current or destination room
is off the open ocean map (vnum < 110000 or not SECT_OCEAN) or the destination
is ROOM_DOCKABLE. The ship must be undocked, landed, mobile and at speed ≤ 20.
Each move sets speed to 0 and starts a 5-tick cooldown (none for immortals).
Entering a dockable room or a zone room (vnum < 110000) requires the crew to
have stood down from battle stations. Arriving in a dockable room docks the ship
and runs customs.
Speed and turning. Orders only set setspeed and setheading, and the
heartbeat closes the gap:
- acceleration per tick =
truncate(class accel × (1 + sail_mod) × stamina_mod), and deceleration uses the same figure; - turn per tick =
class turn rate × (0.75 + 0.25 × (speed − 9) / (class max − 9)) × (1 + sail_mod) × stamina_mod. Slow ships turn worse and fast ships turn better. An immobile ship turns 1° per tick. The turn always takes the short way round. - stamina cost per tick: speed change
(|Δspeed| / accel) / (1 + sail_mod) × (2 + √hull / 10), heading change(|Δheading| / turn rate) / (1 + sail_mod) × (3 + √hull / 10), the latter ×5 if immobile.
Maximum speed. update_maxspeed() recomputes ship->maxspeed whenever
update_ship_status() runs:
if (a surface ship has a breached arc) or mainsail == 0: maxspeed = 0
ceiling = class max speed + crew speed bonus (0-2), x1.2 if flying with no breach
speed = ceiling (x1.2 again if flying with no breach, x0.5 if flying with one breach)
x (1 + sail_mod)
x (1 - (slot weight - free equipment weight - free cargo weight) / max weight)
x mainsail / class max sail
maxspeed = clamp(speed, 1, ceiling)
Free equipment and free cargo weight are per-class allowances
(_freeequip, _freecargo) that do not slow the ship. The effective maximum
also adds maxspeed_bonus (always 0 today), plus 2 for a helmsman with the
SEADOG innate (humans, orcs and swashbucklers). Flying applies its 1.2 to
both the ceiling and the running total. That is an owner decision recorded in
issue #80.
Capacity. A ship will not take orders while it carries more than
_mxpeople people. num_people_in_ship() does not count immortals, NPC-ship
crew mobs (vnums 40201–40299) or images (vnum 250).
Crew model
A ship has one crew (ShipCrew) with three skills and up to three chiefs:
sail_skill(deck),guns_skillandrpar_skill(repair) are floats. They never fall below the hired crew type's base values.sail_chief,guns_chiefandrpar_chiefindexship_chief_data[].NO_CHIEFis 0.staminaagainstmax_stamina, which is the crew type's base stamina.
ShipCrew::update() derives the multipliers the rest of the code reads:
rpar_mod_applied = sqrt(rpar_skill) / 150 + 0.03 x (crew level + crew rpar mod + chief rpar mod)
sail_mod_applied = 0.8 x (sqrt(sail_skill) / 150 + 0.03 x (...sail...)) + 0.2 x rpar_mod_applied
guns_mod_applied = 0.8 x (sqrt(guns_skill) / 150 + 0.03 x (...guns...)) + 0.2 x rpar_mod_applied
Stamina. Stamina is spent by steering, reloading, firing and repairing, and
may go negative. While it is positive the stamina modifier is 1.0. Below zero
it is 1 / (1 + |stamina| / max_stamina / 3), and it scales acceleration,
turning, the chance of each reload tick, hit chance, repair chance and reload
time. Displays show a deficit as -sqrt(|stamina|).
Training. skill_raise() scales each gain by the chief's
skill_gain_bonus percentage. Below the chief's min_skill the crew learns
four times as fast.
| Source | Skill gain |
|---|---|
| Crossing a map room (not in a sloop or yacht) | sail +0.003 |
| Each reload tick against a valid target | guns +0.003 |
| Each shot at a valid target | guns +0.1 |
| Each successful repair | repair +0.1 at battle stations with a valid target, otherwise +0.01 |
| A ram attempt against a valid target | sail +1–3 |
| Selling cargo | sail +1.5 and repair +0.5 per 1,000 platinum of proceeds |
| Selling contraband | sail +1.0 and repair +0.33 per 1,000 platinum of proceeds |
| A kill | all three + the frag award (NPC kill: one tenth of the hull weight) |
"Valid target" (HAS_VALID_TARGET) means a locked target of a different race
that is not sinking.
Casualties. When a ship is sunk, replace_members(p) pulls each skill p%
of the way back to the crew type's base: 10 + frags_lost / 30 percent in PvP,
5 + hull_weight / 100 percent when sunk by an NPC.
Changing crews. Hiring a new crew (change_crew()) keeps trained skills,
first losing 1–5% of each and then flooring at the new type's base. Stamina is
reset.
Repairs at sea. Each tick, while the repair stock is above 0 and the crew
is not mindblasted, the crew may repair. The chances below are per mille, then
multiplied by (1 + rpar_mod_applied) × stamina_mod:
| What | Only below | Anchored | Underway | Per success |
|---|---|---|---|---|
| Sails | max × (rpar_mod + 0.4) and 90% |
500 (with some sail), 100 (no sail, no battle stations), 30 (no sail, battle stations) | 30 (with some sail), 0 | +1–3 sail (crew bonus), −1 stock, −3 stamina |
| Damaged weapon | not destroyed | 200 | 200 | −1–3 damage level, ⅕ chance −1 stock, −1 stamina |
| Internal structure, per arc | max × (rpar_mod + 0.1) and 90% |
250 or 100 if the arc is at 0 (no battle stations); 100 or 10 (battle stations) | 30, 0 if the arc is at 0 | +1–3 (crew bonus), −1 stock, −2 stamina |
| Armour, per arc | only if no internal repair is needed, no battle stations, and rpar_mod > 0.5; below max × (rpar_mod − 0.5) and 90% |
150 | 20 | +1–3, −1 stock, −4 stamina |
The repair stock is refilled to the hull weight on docking. Ships never fully repair at sea: the thresholds cap repairs well short of full, which is why shipwright repairs exist.
Combat
Targeting
lock <id> (captain, a group-mate of the online captain, or an immortal)
selects a contact and puts the crew at battle stations. Docked ships cannot be
locked. Weapons fire only at the locked target. lock off clears it. Docking
or deleting a ship drops every other ship's lock on it
(clear_references_to_ship()).
Firing requires: not sinking, not docked or anchored, on a map room, a locked
target, not mindblasted (T_MINDBLAST) and no ram recovery (T_RAM_WEAPONS).
fire <slot> fires one weapon and fire fore|port|starboard|rear fires every
weapon on that arc. Each weapon must also have the target inside its range
band, have it bearing in its own arc, be undamaged, be reloaded and have ammo.
Arc geometry (get_arc()): relative to the ship's heading, fore is ±40°
(80° wide), starboard 40°–140°, rear 140°–220° and port 220°–320°. The beams
(100° each) are the bigger targets, which is why they carry more armour.
Hit chance
weaponsight() projects both ships one tick ahead (pending turn and speed
change included) and measures how fast the firing solution is moving:
- crossing speed: the target's velocity across the line of fire;
- angle speed: how fast the relative bearing is swinging;
- closing speed: how fast the range is changing.
Ballistic weapons (catapults) weight closing speed heavily. Direct-fire weapons (ballistas, beams) weight crossing and angle speed. The model then:
- starts from 0.5 at maximum range and divides by
1 + speed / 50; - adds
√hull − 3percentage points for the target's size; - shrinks the miss chance as range closes from maximum to a quarter of maximum, with no further gain inside that;
- divides the miss chance by
1 + guns_mod_applied, and multiplies it by 1.5 against a flying target; - multiplies the hit chance by the stamina modifier and clamps it to 1–100%.
volley_hit_event() hits when 2d50 ≥ 100 − N, which is not a flat N%: two
dice push the result towards the extremes. Players are shown the real chance,
volley_hit_percent(N) (rounded), both in look sight and when firing:
N (weaponsight()) |
10 | 20 | 30 | 40 | 50 | 60 | 70 | 80 | 90 |
|---|---|---|---|---|---|---|---|---|---|
| Real chance | 2.6% | 9.2% | 19.8% | 34.4% | 53.0% | 70.4% | 83.8% | 93.2% | 98.6% |
Ramming rolls a flat percentage, so its shown chance is already exact.
Firing and volleys
fire_weapon() announces the shot and spends one round. It starts the reload
at reload_time × (1 − 0.15 × guns_mod_applied) / stamina_mod ticks and spends
stamina (weight / (√hull / 10)). It marks everyone aboard both ships with the
60-second TAG_PVPDELAY, puts the shooter at battle stations, and schedules
volley_hit_event() after (range / max_range) × volley_time pulses, ±10% and
at least 1.
The hit chance is frozen at firing time and carried on the volley, so a
defender cannot dodge a shot already in the air. The volley stores both ships
as ShipRuntimeRef values. If either ship is gone when it lands, or the target
has docked or slipped into a zone room (vnum < 110000), it is dropped. If the
target is out of sight when it lands, it resolves as if fired from bearing 0 at
range 35.
On landing the target is forced to battle stations and its NPC AI (and any NPC escorts) is told who attacked. A hit is resolved fragment by fragment:
- With probability
sail_hit% (if the target has sail left) the fragment hits the sails fordamage × sail_dam%. - Otherwise it hits the hull, on the target arc facing a direction scattered
±hit_arc / 2around the attacker's bearing, fordamage × hull_dam%, with the weapon'sarmor_pierce. - Damage is rolled from
min–max, exceptRANGEDAMbeams, which scale linearly from max damage at minimum range to min damage at maximum range. MINDBLASTsetsT_MINDBLASTto5 + 15 × range_modticks (a third of that against Cyric's Revenge). Inside mid-range it also knocks everyone aboard sitting for two combat rounds. A mindblasted crew ignores orders, stops steering, reloading and repairing, and will certainly crash into a coast.
Damage model
Each arc has armour over internal structure (damage_hull()):
- Armour absorbs first. If the hit does not break through, there is an
armor_pierce% chance of a critical hit that carries half the damage inside (with a 50% weapon-damage chance). Otherwise the hit stops at the armour. - Overkill damage spills into the internal structure (15% weapon-damage chance).
- If that arc's internals are already gone, a third of hits deflect into another arc that still has structure. A hit on a hollowed-out arc always damages a weapon.
- Weapon damage adds
damage × 5to a random surviving weapon on that arc (val2). A weapon is damaged at 1 or more (cannot fire) and destroyed at 100. - One hull hit in nine knocks everyone aboard off their feet.
Before any of that, and before sail damage in damage_sail(), the owner's
Ship Damage Control epic skill reduces the hit while the owner is aboard
(captain_is_aboard()): epic_ship_damage_control() removes 4% plus a fifth
of the skill in percent (24% at 100), never going below 1 point. Damage a
character deals to a hull (ch_damage_hull()) is not reduced.
update_ship_status() clamps values at zero and counts breached arcs
(armour and internal both 0):
- two or more breaches on an undocked ship:
sink_ship(); - one breach:
maxspeed = 0(immobile), except a flying ship, which keeps half speed; - no sail: immobile.
Its attacker argument is what credits the kill, so callers pass it whenever
damage came from a ship.
Ramming
order ram (needs a locked target, speed ≥ 20 and the T_RAM cooldown clear)
sets RAMMING. While it is set, weapon reloads pause. Each tick
ship_activity() calls try_ram_ship() once the target is within 1 room. The
mode stands down if the target is lost or speed falls to 9 or below.
try_ram_ship():
- requires the target inside a 120° cone ahead and both ships at the same "altitude" (flying versus surface ships pass over or under each other);
- computes the closing speed from the heading difference. A target running away faster than the rammer escapes.
- success chance falls as the rammer's hull outweighs the target's and as both
ships' speeds rise (
hull_modis divided by 10 against a stationary target), then is improved by deck skill; - on success, each side takes
(hull + 100) / 10base damage scaled by speed and closing speed, ×1.2 for fore-arc impacts, dealt in 2–6 point hits across arcs and occasionally the sails. A fitted ram adds a heavy fore hit of about its own weight,(hull + 10) / 24, and halves fore-arc crash damage taken. The heavier ship spins the lighter one to a random heading. Both crews are knocked down. - cooldowns:
T_RAM50 ticks after a success or 25 after a failure, andT_RAM_WEAPONS25 ticks after a success. Both are reduced by up to 15% by crew skill.
Sinking and insurance
sink_ship() sets SINKING, stops the ship, lands it if flying, cancels its
autopilot and starts T_SINKING:
| Ship | Sink timer |
|---|---|
| Player ship | 75–150 ticks (1¼–2½ minutes) |
| NPC ship | 1000–1500 ticks, so the wreck can be boarded and looted |
| Cyric's Revenge | 7500 ticks |
Rewards are settled immediately:
- Who gains. The attacker. If the attacker is an NPC escorting a player ship, that player ship gains. Any other NPC attacker credits nobody.
- Frags: the target's hull weight, only across a race boundary. NPC victims
pay no frags (Cyric's Revenge pays a fifth), only crew training of a tenth.
A captain aboard for a 20+ frag kill earns
EPIC_SHIP_PVPprogress. - Salvage: the hull's list price × the fraction of armour and internals
remaining, plus surviving weapons at half list (a tenth if damaged), divided
by
ship.sinking.rewardDivider. It is paid across a race boundary or for any player victim. Sloops are worth nothing. - Bounty:
target frags × 10000 / rewardDividercopper when the target had more than 100 frags and a different race. NPC fit-outs carry frags, so they pay a bounty. - Split. Frags are divided by the number of same-race, non-sloop, undocked
ships in contact with the wreck (NPC escorts of that race count). Salvage and
bounty are divided by the number of non-sloop, undocked ships in contact whose
owners are grouped with the gainer's captain. The gainer and every such
grouped ship are then paid a share. Money goes into the ship's coffers
(
ShipData::money), collected withget moneyat the panel. - Penalty. The victim loses the per-ship frag share (floored at zero) and crew casualties as described above. Nothing is lost when sunk by an NPC.
finish_sinking() runs when the timer expires. An NPC ship with a player aboard
is postponed 30 ticks. Otherwise clear_ship_content() empties the interior:
- players are moved into the room the hull is in;
- every NPC aboard is extracted, pets and followers included;
- loose objects are destroyed, except player corpses, which are moved out.
The hold is then jettisoned (about half becomes floating crates), and:
- Player ships are not destroyed. Insurance is paid: nothing for a sloop, 90% of hull list price if sunk by an NPC, 75% for a merchant hull, 50% for a warship. It goes to the owner's bank as a currency transaction when they are online, otherwise to an auction-house money pickup, and as a last resort into the ship's coffers. The hull is downgraded to a sloop with every slot emptied, the sails are set to 0 (unless sunk by an NPC from a larger hull), and the ship is docked in Davy Jones' Locker. Frags and crew survive.
- NPC ships are erased and deleted.
Flying ships
A Zentharium Levistone (equipment 1, capital) lets any hull above a yacht
fly. order fly needs the stone recharged and a map room. The ship then flies
for 60 ticks and lands automatically, and the stone recharges for 600 ticks.
order land lands early. While flying:
- the ship can cross any map terrain except mountains;
- the speed bonuses above apply, and one breach halves speed instead of immobilising;
- it cannot be boarded or left (immortals excepted), cannot anchor or maneuver, and rams pass over or under it;
- shots at it miss 50% more often;
- it is at altitude:
fly_ship()setsShipData::ztoSHIP_FLYING_ALTITUDE(4 squares) andland_ship()back to 0. Ranges are three-dimensional, so a surface ship three squares away is five from a flying one, and a surface weapon with a reach of 4 or less cannot hit it. Two flying ships are level; - the levistone weighs nothing.
Landing over land stops the ship on the land room, and it must order maneuver
back onto adjacent water. It is not damaged.
Hulls with AIR fly without the timer. Only Cyric's Revenge has it, and its AI
takes off at random.
Ocean PvP state
ocean_pvp_state() is true when two undocked, non-NPC ships of different races
share an ocean map. It uses getcontacts(ship, false), so there is no range
limit. Sloops, and yachts with no weapons, never count. The state:
- doubles sloop and yacht summon times and adds 15 mud hours for larger hulls;
- multiplies hull-change build time by 5 and equipment install time by 4;
- suppresses pirate and hunter ambushes.
It walks every ship and rebuilds the shared contacts[] buffer, so it is
expensive and clobbers any contact index a caller holds.
NPC ships
Spawning
Every tick, each undocked ship that is moving and has no target rolls
1 in (ships.pirate.load.chance + 1). With a diplomat's flag it rolls against
ships.pirate.diplomat.load.chance instead (shipped values 1000 and 60000, code
defaults 7200 and 30000). try_load_pirate_ship() then refuses against NPC
ships, sloops, yachts and during ocean PvP. It rolls
n = random(0, hull weight) + frags:
- Merchant hulls are ambushed once per trip (
ATTACKBYNPC, cleared on docking):n < 250level 0 pirate,< 1200level 1,< 2200(or 3 times in 4) level 2 with a 1-in-3 chance of a hunter, otherwise a level 3 hunter. - Warships must beat a
1..1000roll to be noticed at all, then draw a level 2 hunter (a level 3 one in 3 times).
try_load_npc_ship() places the NPC 45 rooms off the target's bow (±45°),
preferring a hull at least as fast as the target minus 10, and points it back at
the target at full speed. find_ship_setup() picks a random npcShipSetup[]
row for the level. load_npc_ship() creates the ship, gives it a random name
from pirateShipNames[] (players cannot use those names), applies the fit-out,
loads the crew and treasure chest, assigns an X–Z designation and undocks
it. Against a diplomat, any mindblast cannon is swapped for a light beamcannon.
The brain is advanced always at level 3 and above, half the time at level 2
and one time in five at level 1.
| Level | Fit-outs | Frags (sets the bounty) | Crew roster |
|---|---|---|---|
| 0 | 5 clippers, 4 ketches, 2 caravels | 150–300 | Northshore Pirates tier (mobs 40220–40230) |
| 1 | 4 ketches, 3 caravels, 3 corvettes | 500–600 | Twilight Cove tier (40231–40241) |
| 2 | 5 corvettes, 7 destroyers | 700–1000 | Marauders tier (40242–40252) |
| 3 | 3 corvettes, 3 destroyers, 2 frigates | 2000–3000 | Dark Sun tier (40253–40263) |
| 4 | 3 dreadnoughts (+ one SH_ZONE_SHIP row, which "any hull" never picks) |
3000–4000 | Demon tier (40264–40274) |
Crew sizes are 8 (clipper), 9 (ketch), 12 (caravel, corvette), 15 (destroyer),
18 (frigate) and 25 (dreadnought). load_npc_ship_crew() fills berths in order
of importance: captain, first mate, specialists, then grunts. It puts a treasure
chest (objects 40215–40219) aboard and its key on the captain.
The brain
NPCShipAI::activity() runs from the heartbeat. It does nothing off the map,
while sinking, while mindblasted, or when no captain is on the bridge
(killing the captain disables the ship). Modes:
| Mode | Behaviour |
|---|---|
NPC_AI_ENGAGING |
Out of ammo → running. Target gone → cruising. Immobile → fight from where it lies. Land in the way → fire over it and path around it (Dijkstra). Ram when worthwhile. Board when the target is slow enough (a merchant at speed ≤ 9, a warship stopped). Otherwise basic or advanced manoeuvring. |
NPC_AI_RUNNING |
Flee the target, or resume cruising once it is gone. |
NPC_AI_CRUISING |
Pirates and hunters look for a target, escorts shadow their charge, then fall through to leaving. |
NPC_AI_LEAVING |
Cruise, reload and repair slowly, and despawn when allowed. |
NPC_AI_LOOTING, NPC_AI_IDLING |
Placeholder or initial states. |
- Targets are undocked, non-sinking ships of the four player races. Diplomats are never selected, and NPC ships never fight each other. Being shot sets the attacker as the target unless the ship is already engaging or running. An escort shot by its own charge turns hunter.
- Basic combat ranks the four arcs, turns a useful arc onto the target and opens or closes range. Advanced combat predicts both ships several steps ahead, evaluates rotations and broadside destinations, and steers for the best side. Both issue the same orders and use the same firing code as players.
- Boarding loads grunts from the crew roster into the target's rooms: the bridge first, then half the rooms for high tiers or three quarters for low tiers. Each target is boarded at most once. A pirate then steals part of the hold (40–60% of what is taken is lost in transfer and another 40–60% left behind, limited by its own capacity) and leaves. A hunter keeps fighting.
- Despawn. Losing the target or charge starts a 300-tick countdown.
try_unload()fires at the last tick unless a player is aboard. It adds 10 ticks if any undocked player ship is still in contact, so NPC ships do not vanish in front of witnesses. NPC ships are never persisted. - Debugging. An immortal can attach to a spawned ship (
debug_char) to receive AI traces. On any ship's panel, immortals can uselock ai_off,ai_pirate,ai_hunter,ai_escort,ai_advancedandai_basic, andfire pirate|hunter|escort [level]spawns an NPC near the ship.
Cyric's Revenge and the zone ship
Cyric's Revenge is a unique level-4 dreadnought spawned once at boot on a
random ocean room (load_cyrics_revenge(), 20 attempts). It is not respawned
until the next boot. It is a permanent, advanced hunter with the AIR flag and
never initiates combat (find_new_target() returns false for it). Other
differences:
- it pays a fifth of the normal frags;
- its crew regenerates stamina at 15 a tick and it slowly regains ammo in combat;
- it resists mindblast (a third of the stun);
- it takes 7500 ticks to sink;
- it carries a hand-built demon crew, a locked hold (key 40225), and a chest
holding the automatons moonstone core and the nexus key. The nexus stone
itself is in room slot 7 (
get_cyrics_revenge_nexus_rvnum()).
The zone ship ("The Black Pearl", a mobile zone entrance) is disabled
throughout. Its code is commented out in ship_npc.c, ship_base.c and
ship_combat.c, and load_zone_ship() is declared but not defined.
Player autopilot
order sail <0-359|n|e|s|w> <0-35> (or order sail off) needs open sea and no
anchor. engage_autopilot() projects a target room distance + 1 rooms along
the heading on the tactical patch. autopilot_activity() then, each tick:
- steers towards the target room;
- stops to turn when more than 30° off course;
- slows to 60, 30 and 10 inside 2.5, 1.5 and 0.5 rooms;
- stops and reports "Destination has been reached."
It does not avoid land. Running aground stops it, as do anchoring,
maneuvering, sinking and a lost target room. The dormant fleet ("ship group")
functions in ship_auto.c have no callers.
Persistence
What is saved
Player ships only (queue_ship_save() and write_ship() ignore NPC and
unloaded ships). Everything that ship_save_signature() hashes is saved: class,
name, owner, frags, anchor, time, sail, race, coffers, flags, per-arc
armour/internal, the crew index, three skills and three chiefs, and all 16
slots. Maximum armour and internals are not stored, since they come from the
class.
Backends
The backend is selected at compile time by __NO_MYSQL__.
SQL (the production build). Six tables, created by the legacy baseline
(migrations/bootstrap_multithread_safe.sql). No immutable migration has
changed them since:
| Table | Key | Contents |
|---|---|---|
ships |
id, UNIQUE(owner_name) |
ship_name, ship_class, frags, anchor_room, time_played, mainsail, race, money, flags, timestamps |
ship_armor |
(ship_id, side), FK cascade |
armor, internal per side |
ship_crew |
ship_id, FK cascade |
crew_index, skills ×1000, three chiefs |
ship_slots |
(ship_id, slot_index), FK cascade |
slot_type, item_index, position, timer, val0–val4 |
ship_cargo_market_mods, ship_cargo_prices |
indexed (type, port_id, cargo_type) |
Market modifiers and last published prices (type is CARGO or CONTRABAND). |
sql_save_ship() never waits on the database. A new ship takes its ships
id from memory (sql_load_all_ships() reads the highest one at boot), and each
save is one job on the persistence writer, in one transaction: the ships row
inserted or updated by that id, then the armour, crew and slot upserts. Inside a
caller's transaction (a character rename) the same statements join it instead.
sql_load_all_ships() reads every owner's rows at boot and places each ship
through sql_place_ship(). A ship that load_ship() cannot place is destroyed
in memory, and its rows stay in memory to be placed again (see Load above);
nothing reads the tables after boot. sql_delete_ship() queues the delete of
the ships row (children cascade; sql_delete_ship_statement() builds it) and
the invalidation of the retired Redis snapshot key. shutdown_ships() saves
every ship the same way; the shutdown drains the writer after it.
Flat file (__NO_MYSQL__). One checksummed catalog,
<state root>/domains/ship_catalog (magic DURSHIP\0, version 1, atomic
writes, owner pid cross-checked against the identity store). Every field that
becomes an array subscript is range-checked on load and on save
(flat_ship_slot_is_loadable() checks each slot's item index against the table
its own type selects). A missing catalog is first seeded from a legacy
Ships/ directory (ship_index plus version-3 per-owner files), otherwise
created empty. Any load failure is fatal at boot, because the flat file is
the only authority, except a ship the full room pool cannot place, which stays
in the catalog and is retried like an SQL one. The cargo market lives in <state root>/metadata/cargo_market
(magic DURCARGO, SHA-256 trailer).
migrations/tools/migrate_ships.c is the offline migrator from the pre-SQL
flat-file layout (Ships/ship_index and its ship files; not part of the default
build).
When ships are written
queue_ship_save(ship, reason)is the normal path. It marks the ship pending, andflush_pending_ship_saves()(every 2 pulses) writes it unless its signature matches the last durable write. A failure keeps the ship pending with a 1-second retry gate. The docking, purchase, repair, hiring, cargo, combat-reward, sinking and summon paths all queue, and so does a player checkpoint, for the loaded player ship the character owns (not one they stand in).write_ship()writes immediately. Besides the queue's own flush and drain, it is called directly byshutdown_ships(), byrename_ship()andrename_ship_owner(), and by the fallback branch of the player savedo_save_silent()(a character without a pid, or a terminal save type), which writes the character's own loaded ship and reports a failed write as a failed save.rename_ship_owner()restores the old owner and name if the write fails;rename_ship()does not roll back.- Character rename.
rename_character()stores the player row, the ship the character owns (get_ship_from_owner(), wherever they stand) and everything else their name keys in one writer job, so one transaction (sql_rename_character_statements()). That covers the account mapping login reads, a personal locker and its access list, their locker grants, guild roster row and top-fragger credit, and their leaderboard name. Corpses keep the name they were made under. The ship's statements are built under its new owner (begin_ship_owner_change(), thenundo_ship_owner_change()at once), so memory keeps the old owner until the job is stored. Then, on a later pulse, the ship, the guild roster, the name index, the character (if still in the game) and every account list take the new name, and the requester is told; a failed job changes nothing. The rename waits while the character's personal locker is open, since that locker would otherwise save under the old name. - Copyover.
drain_pending_ship_saves()ignores the retry gate. If any pending ship cannot be made durable, the copyover is aborted. - Shutdown.
shutdown_ships()puts every passenger and loose object in a ship at the ship's anchor room, skipping rooms a ship does not have, then saves every ship (on SQL, writer jobs the shutdown drains). A loaded player ship that cannot be saved is treated as corruption (panic_corruption()). - Boot.
initialize_ships()attaches the procedures, loads every ship that fits in the room pool at its anchor (docked), deletes ships flaggedTO_DELETE, loads the cargo market, spawns Cyric's Revenge and seeds the moonstone fragments (skipped during Redis world recovery).
Deletion paths
- Character deletion (
delete_character()insrc/core/files.c) deletes the ship's rows (sql_delete_ship_statement()) in the same writer job that deletes the player. Only after the commit does it forget the stored rows and the Redis snapshot (sql_delete_ship()) and drop the runtime ship withdelete_ship_runtime(). - Account deletion deletes the rows in its writer job. Only after the commit
does
forget_deleted_account_characters()(src/account/account.c) invalidate each character's legacy Redis snapshot and calldelete_ship_runtime(), so a refused deletion keeps the ships. - Immortals can delete a ship with test object 1203 (
say ship delete <owner>). sell shipis disabled, so players cannot delete their own ship.
See CHARACTER_DELETION.md and ACCOUNT_ERASURE.md for the ordering guarantees.
Redis
Ship state is SQL-authoritative. The old Redis ship snapshot routes are retired:
redis_invalidate_ship_snapshot() only enqueues deletion of the legacy
ship:snapshot:<owner> key on a bounded background worker (redis_ship_legacy.c)
using the maintenance identity. scripts/clear-redis.sh also removes those
keys. See CONFIGURATION.md.
GMCP
GMCP-enabled players standing on the bridge receive two packages, flushed every 2 pulses and sent only when their content hash changes:
Ship.Contacts: ownheading,speedand world coordinates, then one entry per contact withid,name,x,y,range,bearing,heading,speed,arc,race(only inside scan range 20 and undocked),status,targeting_youandyou_targeting. It is sent only on the open sea and undocked, and also when the ship changes room. Docked contacts beyond range 5 are omitted, as inlook contacts.Ship.Info:name,id,captain,class,frags,status,maxSpeed(with the recipient'sSEADOGbonus),contactRange,sail,maxSail,crewStamina,maxCrewStamina,repairStock,crewType,chiefs,skills,skillMods,people,maxPeople, per-arcarmorandinternalas[current, max], andweaponsandequipmentarrays with ammo, damage and readiness.
Passengers with GMCP also get Room.Map renders of the ocean around the ship
(src/net/comm.c, src/world/handler.c). Passengers with the PLR2_SHIPMAP
toggle get a text look-out every time the ship changes room.
Integration with the rest of the game
- Frag leaderboard.
update_shipfrags()keeps the top 20 ships by frags.display_shipfrags()prints the top 10 ("10 most dangerous ships") for the frag list. Hardcore scoring adds a character's ship frags. - Achievements. Selling 10,000 cargo crates completes Trader
(
AIP_CARGOCOUNT), which halves build and install times. The Sailor's Tattoo (level 20, or granted at creation to Chaos characters) givesAIP_FREESLOOP. That is 100 platinum off a first hull (a free sloop) at any shipyard, or a free frigate whenCHAOS_STARTER_FRIGATEis enabled (CHAOS_MODE.md). Players who already own a ship get 100 platinum instead. - Innates.
SEADOGadds 2 to maximum speed at the helm and 10% to cargo sale proceeds. - Epic.
EPIC_SHIP_PVPprogress for PvP kills. The Ship Damage Control epic skill reduces sail and hull damage to its owner's ship while the owner is aboard (see Damage model). It is taught only by the headless commodore (mob 2733) in 10-point lessons.epic_teacher()charges three times the table's 80 points and twice its 8,000 platinum, so the first lesson costs 240 epic points and 16,000 platinum, and the price rises with the skill (epic.progressFactor). New Chaos characters with starter epic skills enabled are granted it at 100 (CHAOS_MODE.md). - Economy hooks. Cargo purchases apply
EPIC_BONUS_CARGO. Cargo sale proceeds pass throughcheck_nexus_bonus(NEXUS_BONUS_CARGO). - CTF. A ship carrying a CTF flag (objects 790–792, or a flag carrier aboard)
has its ordered speed cut by
(speed / 12)². - Automatons quest. Two moonstone fragments (object 12028: one on mob 23240 or in room 31724, one on mob 12027) and the core (12029, in Cyric's Revenge's chest) fuse into the moonstone (12001) when carried together. Erzul (mob 9453) trades it for the Magical Automatons crew (3000 frags and 40,000 platinum) or a ring (object 9460).
Extending the system
All table identifiers are persisted, so every change is append-only.
- New hull class. Append a row to
ship_type_data[],ship_arc_properties[],ship_allowed_weapons[]andship_allowed_equipment[]. Add aSH_*constant and bumpMAXSHIPCLASS. Add acaseto bothset_ship_layout()andname_ship_rooms(). Check the room pool can hold the new layout, and the flat-file legacy importer'slegacy_ship_class_count.buy_hull()treats the last class as NPC-only, so inserting beforeSH_ZONE_SHIPchanges which hull is excluded. Update the hull tables in SHIP_GAMEPLAY.md. - New weapon. Append to
weapon_data[]with aWPNFLAGset, add aW_*constant, bumpMAXWEAPON, and add a column to every row ofship_allowed_weapons[]. - New equipment. Append to
equipment_data[], bumpMAXEQUIPMENT, add a column toship_allowed_equipment[], and give it behaviour.ShipSlot::get_weight()special-cases hull-relative weights. - New crew or chief. Append to
ship_crew_data[]orship_chief_data[]with hire rooms, and bumpMAXCREWSorMAXCHIEFSif the table is full (ship_crew_data[]has 24 rows and a 25th zero-filled slot). The hire room must also havecrew_shop_procattached inspecs.assign.c, otherwise the crew only appears in the list-everything room 77. - New port.
NUM_PORTSsizes the port list, both market matrices, the commodity names,cargo_location_data[]andcargo_location_mod[], and the persisted slot index range for cargo. Adding one changes the flat-file cargo record size and the maintenance snapshot layout, so treat it as a migration. - New NPC fit-out. Write a
setup_npc_*()function and add onenpcShipSetup[]row. Nothing else is needed.
After any change: make -C src, then the ship tests below. Update both
documents.
Tests
The focused regressions live in tests/async/. Run them directly, for example
python3 tests/async/test_ship_save_guards.py.
| Test | Covers |
|---|---|
test_ship_save_guards.py, run_ship_save_guards.sh |
Every mutation path queues a save. Both backends carry every slot field. |
test_ship_save_queue_dedup.py, run_ship_save_queue_dedup.sh |
Signature-based save coalescing. |
test_ship_rename_save_guards.py, run_ship_rename_save_guards.sh |
Rename persistence ordering. |
test_ship_owner_rename_failure.py, run_ship_owner_rename_failure.sh |
rename_ship_owner() rollback. |
test_ship_save_ids_in_memory.py, test_ship_shutdown_txn.py |
A new ship's id comes from memory and each save is one writer job (or joins a caller's transaction); the shutdown saves the same way (real sql_save_ship()). |
test_ship_cargo_txn.py, test_auction_ship_txn_fixes.py |
The cargo market save is one writer job, and a ship save updates its owner by id. |
test_character_rename_ship_ownership.py |
Character renames store the player row, everything the name keys and the owned ship in one writer job, memory follows only once it is stored, and a paid rename is charged once, then. Faults are injected in each statement and a refused job, and a linkdead target, a target or requester who leaves before the reply, a never-stored ship and an open locker are covered (real rename_character(), rename hook and sql_rename_character_statements()). |
run_character_rename_references_mysql.sh |
The rename's reference updates against MySQL/MariaDB tables shaped like production. |
test_ship_load_clears_summon.py |
load_ship() drops a stale SUMMONED. |
test_ship_boot_loads_every_row.py |
sql_load_all_ships() loads more than 512 rows and reads the highest ship id. |
test_ship_boot_room_pool_full.py |
Booting more ships than the room pool holds: all-or-nothing room claims, unplaced ships destroyed with their rows kept and placed again as rooms free up, the hull-change room check, and a clean shutdown (real loader, layout, retry and shutdown_ships(), ASan/UBSan). |
test_ship_purchase_room_guards.py |
A hull change that the pool cannot hold is refused or refunded, and a first-ship purchase by an owner with a stored ship brings it back instead (real ship_hull_purchase_committed()). |
test_ship_documented_bugs.py |
The fixes for the bugs this document listed: true volley chances, crash speed, disembark edge, fleet frag loss, contraband stacking and alignment gate, summon quote, Ship.Info, counter-ram, zone-ship picks, stale comments and help drift. |
test_ship_boarders_use_target_rooms.py |
Pirate boarders land only in the target's own, non-contiguous rooms. |
test_ship_damage_control.py |
Ship Damage Control reduces sail and hull damage with the owner aboard. |
test_player_save_owned_ship.py |
A player save queues or writes the ship the player owns, never the one they stand in (real do_save_silent()). |
test_ship_name_purchase.py |
Coloured names and the pending hull-purchase context. |
test_ship_shop_list_contract.py |
Shipwright listing output. |
test_ship_autopilot_group_safety.py |
Autopilot bounds and message audiences. |
test_nevent_ship_volley_runtime.py |
Volley lifetime against ship deletion and slot reuse (ASan/UBSan). |
test_flatfile_ship_repository.py |
Flat-file catalog round trip, validation and legacy import. |
test_redis_ship_snapshot_invalidation.py |
Retired snapshot key invalidation. |
test_ferry_ship_lifetime.py |
Ferry ship rooms (the separate ferry system). |
Known issues and discrepancies
These were found by reading the code while writing this document, and are all
resolved. test_ship_documented_bugs.py pins the code bugs. The two that needed
a decision were settled by wiring them in: flying ships have altitude (see
Flying ships, test_ship_altitude_and_market_bounds.py),
warship.sails.damage.reduction cuts sail damage to warships
(test_ship_damage_control.py), and the cargo and contraband price bands hold
the markets (see SHIP_GAMEPLAY.md).